feat: complete dashpay in platform wallet and swift example app#3841
feat: complete dashpay in platform wallet and swift example app#3841shumkov wants to merge 261 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds DashPay SPEC/research docs and implements DIP-15 compact-xpub handling, tightened key-purpose validation, rejected-request tombstones and payment-channel-broken tracking, SDK writer seam, recurring DashPay sync manager, incoming-payment recording/reconciliation, FFI extensions (payments/sync/persistence/seed attach), SwiftData models, and SwiftExampleApp UI and tests. ChangesDashPay Spec & Research
Crypto & SDK
Validation, State & Storage
FFI & Persistence
SDK writer seam & wallet integration
Contact flow refactor
Payments, reconciliation & event bridge
Recurring sync manager
Swift SDK and Example App
Estimated code review effort 🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit bb02c41) |
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (1)
806-875:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftThe accept-adopt check is only local, not platform-aware.
already_reciprocatedis derived from localsent_contact_requests/established_contacts, but the sync code above explicitly allows "received loaded, sent fetch failed" by logging and continuing. In that state the reciprocal already exists on Platform whilealready_reciprocatedis still false here, so this path retries the same(ownerId, toUserId, accountReference)write and gets the unique-index rejection instead of adopting. This needs a platform check here, or a duplicate-send fallback that switches to the adopt path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs` around lines 806 - 875, The local-only already_reciprocated check (variable already_reciprocated) can be stale; change the flow so before attempting send_contact_request_with_external_signer you either (A) perform a platform check for an existing reciprocal contact request/relationship (use whatever network client/query you have for checking platform contact requests for (ownerId,toUserId,accountReference)) and set already_reciprocated accordingly, or (B) keep the existing local check but add a duplicate-send fallback: catch the unique-index conflict/error returned by send_contact_request_with_external_signer and, on that specific error, log that the reciprocal exists on Platform and run the adopt path (call register_contact_account(&our_identity_id, &sender_id, 0) and treat as success). Reference already_reciprocated, send_contact_request_with_external_signer, and register_contact_account when implementing either fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/dashpay/research/01-dip-spec.md`:
- Line 131: Several fenced code blocks use plain ``` without a language tag;
update each triple-backtick fence in the document (e.g., the blocks currently
shown as ``` at the indicated locations) to include an explicit language token
(for non-code or prose use `text`, or a specific language like `json`, `bash`,
`markdown` where applicable) so the markdown linter passes; search for all
occurrences of ``` (including the ones noted around 131, 194, 245, 289, 418,
455) and replace them with ```text or the appropriate language identifier.
In `@docs/dashpay/research/02-rust-dashcore-keywallet.md`:
- Line 232: The markdown contains fenced code blocks without language tags;
update the offending triple-backtick fences to include the appropriate language
identifier (e.g., ```rust, ```bash, or ```text) for the code snippets so
markdownlint passes and syntax highlighting works—locate the plain ``` fences in
the document (the blocks referenced in the review) and replace them with
language-tagged fences.
In `@docs/dashpay/research/05-swift-app.md`:
- Line 47: The fenced code block currently uses a bare triple-backtick fence
(```); add a language tag (e.g., ```swift or ```text) immediately after the
opening backticks to satisfy markdownlint and enable proper syntax highlighting
for that block.
In `@docs/dashpay/research/06-interop-desk-check.md`:
- Line 366: The fenced code block uses plain ``` without a language tag; update
the opening fence to include an appropriate language identifier (for example
`http`, `text`, or `bash`) so markdownlint is satisfied and readability
improves—locate the triple-backtick fenced block in the document and add the
language tag immediately after the opening ``` fence.
- Line 24: The table row contains an extra leading column ("2") so it has four
columns while the table header defines three; remove the extra column in the row
that contains "2" (the row with "ECDH shared-key derivation" and the
libsecp256k1 SHA256 expression) so the row matches the 3-column header layout,
keeping the description "ECDH shared-key derivation" and the verdict "**PASS** —
all three stacks compute libsecp256k1-style `SHA256((y[31]&0x1|0x2) ‖ x)`" as
the remaining columns.
In `@docs/dashpay/SPEC.md`:
- Line 111: Several fenced code blocks in SPEC.md are missing language
identifiers; update each triple-backtick fence (``` ) at the noted examples so
they include an appropriate language tag (e.g., change ``` to ```text, ```rust,
or ```swift as appropriate) to satisfy markdownlint and enable correct syntax
highlighting; search for the bare ``` occurrences (including the ones referenced
near the examples) and replace them with language-tagged fences, ensuring
opening and closing fences remain paired.
In `@packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- Around line 221-223: The background loop cleanup currently unconditionally
sets this.background_cancel to None (in the block near start()), which can
overwrite a newer token if stop() and start() race; change the logic so the
background thread only clears background_cancel if the stored cancel token it
captured at spawn time still matches the current token in this.background_cancel
(i.e., capture the Arc/ID of the cancel handle when spawning and
compare-before-clearing); apply the same compare-and-clear pattern in the stop()
/ thread-exit cleanup (references: this.background_cancel, start(), stop()) so a
late-exiting old loop cannot null out a replacement token.
In `@packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- Around line 103-118: The sender/recipient key selection currently using
sender_identity.public_keys().iter().find(...) (checking Purpose::ENCRYPTION and
KeyType::ECDSA_SECP256K1) can pick a disabled/rotated key; update the logic to
only consider active/enabled keys (e.g., filter by .enabled() or reuse the
existing enabled-key selection utility used for signing) so
sender_encryption_key and recipient_key_index (the call to
select_recipient_key_index should be updated similarly or replaced) always
reference the current active ENCRYPTION/DECRYPTION ECDSA_SECP256K1 key; ensure
you still call .map(...).ok_or_else(...) and preserve error type
PlatformWalletError::InvalidIdentityData when no active key is found.
- Around line 516-556: collect_account_build_candidates currently skips contacts
when info.core_wallet.accounts.dashpay_external_accounts.contains_key(&key) is
true, which prevents retries if register_contact_account previously failed after
inserting an external entry; remove that gating so contacts with an
incoming_request (incoming.encrypted_public_key and key indices) are always
returned as AccountBuildCandidate (unless payment_channel_broken) to allow
build_contact_accounts -> register_contact_account to retry; specifically, in
collect_account_build_candidates remove or change the has_external
check/continue and rely on contact.incoming_request and payment_channel_broken
to decide inclusion (keep AccountBuildCandidate fields: contact_id,
encrypted_public_key, our_decryption_key_index, contact_encryption_key_index).
- Around line 452-509: parse_contact_request_doc currently only extracts
required fields and drops optional fields encryptedAccountLabel and
autoAcceptProof, causing restores to lose these values; update
parse_contact_request_doc (and thus parse_sent_contact_request_doc which calls
it) to also read props.get("encryptedAccountLabel").and_then(|v: &Value|
v.as_str()).map(|s| s.to_owned()) and props.get("autoAcceptProof").and_then(|v:
&Value| v.as_bytes()).cloned() (or appropriate conversions) and pass them into
ContactRequest::new (or the appropriate constructor/factory) so the
ContactRequest created preserves encryptedAccountLabel and autoAcceptProof
during ingest/reconcile. Ensure the match arm pattern includes these Option
values and the fallback logging remains unchanged.
In
`@packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- Around line 116-130: The code removes an incoming request from
self.incoming_contact_requests but the returned ContactChangeSet only records
cs.rejected, so on replay the incoming entry isn't removed; update the change
set returned by the function to also include the incoming-removal for (owner_id,
*sender_id, account_reference) (i.e., add the corresponding removal entry to the
ContactChangeSet alongside cs.rejected) so that replay will delete the
incoming_contact_requests entry when applying the rejection tombstone.
---
Outside diff comments:
In `@packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- Around line 806-875: The local-only already_reciprocated check (variable
already_reciprocated) can be stale; change the flow so before attempting
send_contact_request_with_external_signer you either (A) perform a platform
check for an existing reciprocal contact request/relationship (use whatever
network client/query you have for checking platform contact requests for
(ownerId,toUserId,accountReference)) and set already_reciprocated accordingly,
or (B) keep the existing local check but add a duplicate-send fallback: catch
the unique-index conflict/error returned by
send_contact_request_with_external_signer and, on that specific error, log that
the reciprocal exists on Platform and run the adopt path (call
register_contact_account(&our_identity_id, &sender_id, 0) and treat as success).
Reference already_reciprocated, send_contact_request_with_external_signer, and
register_contact_account when implementing either fix.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 596c3a94-3c49-4cc0-869e-b392a37c181e
📒 Files selected for processing (38)
docs/dashpay/SPEC.mddocs/dashpay/research/01-dip-spec.mddocs/dashpay/research/02-rust-dashcore-keywallet.mddocs/dashpay/research/03-rs-platform-wallet.mddocs/dashpay/research/04-sdk-and-contract.mddocs/dashpay/research/05-swift-app.mddocs/dashpay/research/06-interop-desk-check.mdpackages/rs-platform-encryption/Cargo.tomlpackages/rs-platform-encryption/src/lib.rspackages/rs-platform-wallet-ffi/src/established_contact.rspackages/rs-platform-wallet-storage/migrations/V001__initial.rspackages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rspackages/rs-platform-wallet-storage/src/sqlite/schema/identities.rspackages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rspackages/rs-platform-wallet/src/changeset/changeset.rspackages/rs-platform-wallet/src/changeset/mod.rspackages/rs-platform-wallet/src/lib.rspackages/rs-platform-wallet/src/manager/accessors.rspackages/rs-platform-wallet/src/manager/dashpay_sync.rspackages/rs-platform-wallet/src/manager/mod.rspackages/rs-platform-wallet/src/wallet/apply.rspackages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rspackages/rs-platform-wallet/src/wallet/identity/crypto/validation.rspackages/rs-platform-wallet/src/wallet/identity/network/account_labels.rspackages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rspackages/rs-platform-wallet/src/wallet/identity/network/contacts.rspackages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rspackages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rspackages/rs-platform-wallet/src/wallet/identity/network/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/profile.rspackages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rspackages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rspackages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rspackages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rspackages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rspackages/rs-platform-wallet/src/wallet/platform_wallet.rspackages/rs-sdk-ffi/src/dashpay/contact_request.rspackages/rs-sdk/src/platform/dashpay/contact_request.rs
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## v4.1-dev #3841 +/- ##
=============================================
- Coverage 87.18% 52.54% -34.64%
=============================================
Files 2632 11 -2621
Lines 327563 1707 -325856
=============================================
- Hits 285592 897 -284695
+ Misses 41971 810 -41161
🚀 New features to boost your workflow:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
M1 of the DashPay completion plan: the SDK entropy / DIP-15 compact xpub / key-purpose interop fixes are correct, but six in-scope correctness issues block merge. The most concerning is editing V001 in-place (violates the documented append-only migration policy and bricks DB rehydration for the v4.0.0-beta.4 cohort). Additional blockers: the reject path emits an incomplete ChangeSet (no removed_incoming); the new rejected_contact_requests table is written but never read; transient identity fetches in register_external_contact_account are misclassified as permanent and brick the channel; validation.purpose_mismatch is set even when a hard error is also present, masking permanent failures as retryable; and the sync sweep skips superseding requests from established contacts, making the documented payment_channel_broken recovery path unreachable.
🔴 6 blocking | 🟡 2 suggestion(s)
2 additional finding(s) omitted (not in diff).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V001__initial.rs:186-213: V001 migration edited in-place violates append-only policy and breaks upgrade from v4.0.0-beta.4
This PR adds `contacts.payment_channel_broken` and a new `rejected_contact_requests` table by editing V001 directly. V001 (without these additions) was already shipped in `v4.0.0-beta.4` (commit da9d3fe84e / schema confirmed via `git show`), and `packages/rs-platform-wallet-storage/README.md:106` explicitly states migrations are append-only and applied by refinery on every `open`. refinery checksums each migration in `refinery_schema_history`; against an existing v4.0.0-beta.4 DB it will either abort with a divergent-checksum error or silently skip V001 (already applied) — in which case neither the new table nor the new column is ever created, and the first runtime write in `contacts.rs:240` (`INSERT INTO rejected_contact_requests …`) or `contacts.rs:194-212` (`payment_channel_broken` column) fails at the SQLite layer. `tc029_migration_fingerprint_stable` does not catch this because it only checks self-stability, not a pinned hash. Add `V002__dashpay_reject_and_broken_channel.rs` doing `ALTER TABLE contacts ADD COLUMN payment_channel_broken INTEGER` and `CREATE TABLE rejected_contact_requests (…)`; the loader at `contacts.rs::load_state` already tolerates NULL `payment_channel_broken`, so a default-less ALTER is compatible.
In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs:109-131: `record_rejected_contact_request` removes incoming in memory but does not emit `removed_incoming`
The function calls `self.incoming_contact_requests.remove(sender_id)` and returns a `ContactChangeSet` populated only with `cs.rejected`. The unified-`contacts`-table writer at `rs-platform-wallet-storage/src/sqlite/schema/contacts.rs:182-193` only `DELETE`s when `cs.removed_incoming` is non-empty, so the previously persisted state='received' row (with the `incoming_request` blob) stays in SQLite. Once `persister.load()` (TODO at `sqlite/persister.rs:909`) is wired up, the unified contacts reader rebuckets that row as an incoming request, `apply_changeset` re-inserts it into `incoming_contact_requests`, and the FFI surfaces the explicitly-rejected request back to the UI. The persisted delta is also internally inconsistent with the in-memory mutation — a delta-persistence invariant violation. The in-memory `rejected_tombstone_round_trips_and_respects_account_reference` test does not catch this because it round-trips via `apply_changeset`, not the SQLite reader. Fix by also inserting a matching `ReceivedContactRequestKey { owner_id, sender_id: *sender_id }` into `cs.removed_incoming`.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: `rejected_contact_requests` is written but never read — tombstones lost across restart
The PR adds a writer (`contacts.rs:240`), a migration row (`V001__initial.rs:203`), and an `apply_changeset` branch that restores `ManagedIdentity.rejected_contact_requests` from `cs.rejected`. But `managed_identity_from_entry` hard-codes `rejected_contact_requests: Default::default()` (line 214), and grep confirms no `load_state` reader for the new table. Once `persister.load()` (TODO at `sqlite/persister.rs:909`) is wired up, the in-memory tombstone map is always empty after restart even though SQLite holds the rows. `is_request_rejected` then returns `false`, the sweep's tombstone-skip in `network/contact_requests.rs:396-404` does not fire, and the recurring DashPay loop (G12) resurrects every rejected request on the first sweep — exactly the M1 failure mode the SPEC.md cites as the reason G5 must land with G12. Add a per-wallet `load_state` for `rejected_contact_requests` and route its output into the `ContactChangeSet.rejected` synthesized during rehydration.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:711-728: Transient identity fetch failures inside account-build are marked as permanent
`build_contact_accounts` treats any error from `register_external_contact_account` as permanent and calls `mark_contact_channel_broken`. But `register_external_contact_account` (`network/contacts.rs:400-407`) performs another `Identity::fetch` for the same contact and wraps the DAPI/network error as `PlatformWalletError::InvalidIdentityData`. A transient DAPI hiccup after validation therefore permanently disables the payment channel; subsequent sweeps skip the contact via the `payment_channel_broken` filter at line 530, and recovery only fires if a superseding contactRequest happens to arrive — contradicting the policy in the docstring at lines 573-578 ("Transient (identity fetch / network): logged, left for the next sweep to retry. The broken flag stays clear."). The fix is to perform the contact-identity fetch and treat its failure as transient *before* calling `register_external_contact_account` (mirroring the existing fetch at lines 631-655) and to scope the permanent-broken classification to genuinely non-recoverable failures (decrypt/decode, missing-key, key-type mismatch).
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:383-392: Superseding requests from established contacts are skipped — `payment_channel_broken` recovery is unreachable
The received-side ingest drops every doc whose sender is already in `established_contacts` before consulting `accountReference`. `EstablishedContact::reestablish_preserving_metadata` exists precisely to clear `payment_channel_broken = false` when a fresh request flows in (see `types/dashpay/established_contact.rs:84-104`), and `collect_account_build_candidates` documents the recovery contract at lines 528-529 ("never retry a permanently-broken channel — wait for a superseding request (which clears the flag on re-establish)"). But there is no path that reaches `reestablish_preserving_metadata` for an already-established sender from the sync sweep — `add_incoming_contact_request` is only called for new senders here, and the send-side guard at `state/managed_identity/contact_requests.rs:46-48` similarly returns early for established contacts. Net effect: once `payment_channel_broken` is set, it stays set forever. Either (a) detect a superseding incoming request (new `accountReference` for the same sender) and route it through the reestablish path, or (b) change the broken-channel policy so the next sweep can retry under controlled conditions.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:5733-5749: `select_recipient_key_index` returns disabled keys
The send-side recipient-key selector iterates `recipient_identity.public_keys()` and returns the first key whose purpose is DECRYPTION (then ENCRYPTION) and whose type is ECDSA_SECP256K1, with no `disabled_at` check. `validate_contact_request` in `crypto/validation.rs` does gate on disabled keys, so if a preferred DECRYPTION key has been rotated/disabled this selector returns it anyway and the broadcast fails downstream with an opaque error instead of falling through to a usable ENCRYPTION key on the same identity. Add `&& k.disabled_at().is_none()` to both branches so selection is consistent with validation.
In `packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs:50-62: `purpose_mismatch` is set even when a non-purpose hard error is also present
The docstring at lines 19-29 contracts `purpose_mismatch` as `true` *only* when the sole reason for invalidity is a key-purpose mismatch — it is what tells `build_contact_accounts` at `network/contact_requests.rs:689` to treat the failure as a non-permanent skip instead of marking the channel permanently broken. The implementation does not preserve that invariant: `add_purpose_error` unconditionally sets `purpose_mismatch = true`, and `add_error` never clears it. A request whose key has both a wrong key type (hard, permanent error) and a wrong purpose ends up with `is_valid = false, purpose_mismatch = true`, and the caller skips + retries forever instead of marking broken. The mobile testnet census makes this rare in practice today, but the classifier is the load-bearing primitive the recovery policy is built on — fix `add_error` to clear the flag, and fix `add_purpose_error` to only set it if no prior hard error was recorded.
In `packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/dashpay_sync.rs:192-227: `stop()` followed quickly by `start()` can let the old thread null out the new cancel token
`stop()` takes the current `background_cancel`, sets it to `None`, then cancels the old token. The spawned thread exits its loop on cancellation and at lines 221-223 re-acquires the guard and writes `*guard = None`. If `start()` runs between `stop()` and the old thread's cleanup block, the old thread's final clear will overwrite the new token just installed by `start()` — leaving `background_cancel` empty while a fresh sync thread is still running, so a subsequent `stop()`/`quiesce()` will be a no-op against that running thread. The normal shutdown path (`quiesce` waits for in-flight passes) does not hit this, but bare `stop()`/`start()` races can. Fix by capturing the token at spawn time and only clearing the guard if it still holds that same token (`if matches!(*guard, Some(t) if Arc::ptr_eq(...)) { *guard = None; }`).
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift (1)
421-465: ⚡ Quick winAlso assert the payment rows roll back in this atomicity test.
The doc comment says a mid-round
persistDashpayPaymentswrite must ride the open changeset and roll back with it, but the test only checksPersistentDashpayContactRequest. If payment persistence starts auto-saving again, this still passes. Add aPersistentDashpayPaymentfetch before and afterendChangeset(..., success: false)so the regression is pinned end-to-end.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift` around lines 421 - 465, In testPaymentRefreshDoesNotCommitAnOpenChangesetRound, add assertions that verify the payment row staged by persistDashpayPayments is not visible mid-round and is rolled back after endChangeset(..., success: false); specifically, call the existing payment-fetch helper (or add/rename a fetch function for PersistentDashpayPayment rows) to assert count == 0 immediately after the mid-round persist and again after handler.endChangeset(..., success: false), mirroring the contact-row assertions so the test verifies payment atomicity as well as contact atomicity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 1919-1925: persistDashpayPayments is swallowing failures from
backgroundContext.save() via try?, which can silently drop payment-history
updates; change the save to propagate or log errors instead of ignoring them:
replace the try? backgroundContext.save() with a throwing or do/catch path
inside persistDashpayPayments that captures the thrown error from
backgroundContext.save(), records telemetry/logging (or rethrows to the caller)
with context (e.g., include which payment batch or wallet ID), and preserve the
existing inChangeset check (if !self.inChangeset) so the save still only runs
when appropriate; update callers or function signature as needed to handle
propagated errors or ensure telemetry is emitted in the catch.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- Around line 35-40: The optimisticSentIds and ownProfile state are
identity-scoped but currently persist across identity switches; update the
activeIdentity handling (the Task that observes activeIdentity) to reset
identity-scoped UI state at the start of the task: clear optimisticSentIds and
set ownProfile to nil (or otherwise remove cached profile) before loading;
alternatively refactor optimisticSentIds and ownProfile to be keyed by owner
identity (e.g., a dictionary keyed by activeIdentity.id) and read/write via that
key, and ensure loadOwnProfileFromCache() does not retain the previous profile
on read failure for the new identity but returns nil so the UI doesn’t show the
old identity’s data.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift`:
- Around line 1235-1243: The avatar downloader currently accepts any parseable
URL, allowing non-HTTPS schemes; update fetchAvatarBytes to explicitly validate
the URL scheme and reject anything not exactly "https" before creating the
URLRequest, returning an error (or nil) for non-https inputs; locate
fetchAvatarBytes (and the analogous implementation referenced around lines
1390-1410) and add a guard that checks url.scheme?.lowercased() == "https" and
fails early with a clear error to prevent http or other schemes from being
fetched.
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift`:
- Around line 92-97: Replace the immediate existence check on the toolbar
refresh button with a timed wait to avoid flakes: locate the `refresh` query
using `Identifier.refreshButton` in DashPayTabUITests (variable `refresh`) and
change the assertion to call `refresh.waitForExistence(timeout: ...)` instead of
checking `refresh.exists`, keeping the same failure message; choose a reasonable
timeout (e.g., 1–5s) consistent with other tests.
---
Nitpick comments:
In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift`:
- Around line 421-465: In testPaymentRefreshDoesNotCommitAnOpenChangesetRound,
add assertions that verify the payment row staged by persistDashpayPayments is
not visible mid-round and is rolled back after endChangeset(..., success:
false); specifically, call the existing payment-fetch helper (or add/rename a
fetch function for PersistentDashpayPayment rows) to assert count == 0
immediately after the mid-round persist and again after
handler.endChangeset(..., success: false), mirroring the contact-row assertions
so the test verifies payment atomicity as well as contact atomicity.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9c0b4a7c-c449-41c7-bd16-7979ff30c777
📒 Files selected for processing (42)
docs/dashpay/SPEC.mdpackages/rs-platform-wallet-ffi/src/contact_persistence.rspackages/rs-platform-wallet-ffi/src/dashpay_payment.rspackages/rs-platform-wallet-ffi/src/dashpay_sync.rspackages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet-ffi/src/manager.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet/src/changeset/core_bridge.rspackages/rs-platform-wallet/src/error.rspackages/rs-platform-wallet/src/manager/attach_seed.rspackages/rs-platform-wallet/src/manager/dashpay_sync.rspackages/rs-platform-wallet/src/manager/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/contacts.rspackages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rspackages/rs-platform-wallet/src/wallet/identity/network/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/payments.rspackages/rs-sdk-ffi/src/sdk.rspackages/rs-sdk/src/platform/dashpay/contact_request_queries.rspackages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayPayment.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDashPaySync.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactDetailView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayContactMeta.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/SendDashPayPaymentSheet.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swiftpackages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift
💤 Files with no reviewable changes (1)
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift
✅ Files skipped from review due to trivial changes (3)
- packages/rs-platform-wallet-ffi/src/lib.rs
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift
- docs/dashpay/SPEC.md
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
- packages/rs-platform-wallet/src/manager/mod.rs
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift (1)
23-26:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winScope the persisted active-identity key by network.
dashpay.activeIdentityIdis shared across every network, so selecting an identity on testnet/devnet overwrites the remembered choice for mainnet too. When the user switches back,activeIdentityfalls back to the first eligible identity instead of restoring the last selection on that network.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift` around lines 23 - 26, The persisted AppStorage key stored in DashPayTabView (`@AppStorage("dashpay.activeIdentityId") private var storedIdentityId`) is global across networks; change it to be network-scoped by deriving the key from the current network identifier (e.g., include network.rawValue or chainId) so each network has its own stored key. Update DashPayTabView to compute the AppStorage key at runtime (or use a computed property / wrapper that returns "dashpay.activeIdentityId.\(networkId)") using the view’s network/environment value and ensure storedIdentityId is read/written through that network-scoped key so switching networks preserves separate selections.
♻️ Duplicate comments (1)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift (1)
169-177:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReset identity-scoped state before loading the next identity.
optimisticSentIdsandownProfilestill survive an identity switch, andloadOwnProfileFromCache()explicitly keeps the previous profile on a read failure. That can render identity A's pending-request overlay or profile header under identity B until the cache catches up. Clear those fields at the start of the.task(id:)block, and don't retain the previousownProfilein the failure path for the new identity.Also applies to: 420-433
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift` around lines 169 - 177, The task block keyed by .task(id: activeIdentity?.identityId) is not resetting identity-scoped state: clear optimisticSentIds and ownProfile immediately at the top of that task before calling loadOwnProfileFromCache() and walletManager.dashPaySyncNow(); and update loadOwnProfileFromCache() so that on a cache read failure for the new identity it does not retain the previous ownProfile (set ownProfile to nil or replace with an empty/default value) instead of keeping the old profile. Ensure the reset refers to the existing properties optimisticSentIds and ownProfile and the loadOwnProfileFromCache() function so the UI doesn't show the previous identity while the new identity's cache is loaded.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift`:
- Around line 35-42: The visible-empty-state logic is still checking the raw
accounts collection instead of the filtered/sorted list, causing the UI to hide
the "No Accounts" state when only accountType 12/13 are present; update the
empty-state checks to use orderedAccounts.isEmpty (or introduce a
visibleAccounts computed collection that filters out accountType 12/13 and reuse
it everywhere) and replace any usages of accounts.isEmpty / !accounts.isEmpty in
AccountListView with checks against that filtered collection so the UI matches
the displayed list (keep AccountListView.sortKey as the sorting helper).
---
Outside diff comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- Around line 23-26: The persisted AppStorage key stored in DashPayTabView
(`@AppStorage("dashpay.activeIdentityId") private var storedIdentityId`) is
global across networks; change it to be network-scoped by deriving the key from
the current network identifier (e.g., include network.rawValue or chainId) so
each network has its own stored key. Update DashPayTabView to compute the
AppStorage key at runtime (or use a computed property / wrapper that returns
"dashpay.activeIdentityId.\(networkId)") using the view’s network/environment
value and ensure storedIdentityId is read/written through that network-scoped
key so switching networks preserves separate selections.
---
Duplicate comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- Around line 169-177: The task block keyed by .task(id:
activeIdentity?.identityId) is not resetting identity-scoped state: clear
optimisticSentIds and ownProfile immediately at the top of that task before
calling loadOwnProfileFromCache() and walletManager.dashPaySyncNow(); and update
loadOwnProfileFromCache() so that on a cache read failure for the new identity
it does not retain the previous ownProfile (set ownProfile to nil or replace
with an empty/default value) instead of keeping the old profile. Ensure the
reset refers to the existing properties optimisticSentIds and ownProfile and the
loadOwnProfileFromCache() function so the UI doesn't show the previous identity
while the new identity's cache is loaded.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5cf1916b-35bc-47ca-bb9d-48b3d9493945
📒 Files selected for processing (4)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift
💤 Files with no reviewable changes (1)
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
All 8 prior findings against 9f770b8 remain STILL VALID at a51606d — verified directly against the worktree (V001 unchanged, record_rejected_contact_request still omits removed_incoming, no reader for rejected_contact_requests, transient identity fetch still permanently breaks channels, purpose_mismatch still sticky, established-contact ingest still skips superseding requests, dashpay_sync cleanup still clobbers cancel token unconditionally, select_recipient_key_index still ignores disabled_at). The M2 delta also introduced one new blocker: Swift wallet deletion does not pre-delete the newly added PersistentDashpayPayment children whose owner inverse is non-optional, mirroring the contact-request pattern that the surrounding comment explicitly calls out as fatal. One FFI suggestion is worth flagging: the new DashpayPaymentFFI derives Copy despite owning two *mut c_char allocations reclaimed by dashpay_payment_array_free. Overflow: 1 valid suggestion dropped (register_external_contact_account persist outside write lock — conf 0.55).
🔴 2 blocking | 🟡 2 suggestion(s)
2 additional finding(s) omitted (not in diff).
6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests is written but never read — tombstones lost across restart
Verified at HEAD: managed_identity_from_entry still hard-codes rejected_contact_requests: Default::default() at line 214. The writer at contacts.rs:240 (INSERT INTO rejected_contact_requests) and migration row at V001:203 exist, but there is no load_state reader for the new table and apply_changeset only handles live deltas — restored state is always empty. The recurring DashPay loop's tombstone-skip at network/contact_requests.rs:396-404 never fires after a restart, so a rejected contact request is resurrected on the first sweep. Add a per-wallet load_state for rejected_contact_requests and route its output into the ContactChangeSet.rejected synthesized during rehydration.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:2980-2996: Wallet deletion omits new DashPay payment children before deleting identities — same fatal pattern as contact requests
Verified: PersistentDashpayPayment.owner is declared as non-optional (`public var owner: PersistentIdentity`), and the new cascade relationship was added on PersistentIdentity.dashpayPayments in the M2 delta. The PHASE 1 pre-delete loop at lines 2986-2996 iterates dpnsNames, dashpayProfile, and contactRequests — but not dashpayPayments. The surrounding comment (lines 2962-2978) explicitly states this phase exists because SwiftData fatals during save() when it must null out a non-optional inverse on a child processed in the same delete batch. dashpayPayments has the exact same shape as contactRequests, so a wallet with persisted DashPay payments can crash or fail to wipe cleanly when deleted. Add a `for payment in Array(identity.dashpayPayments) { backgroundContext.delete(payment) }` loop alongside the existing pre-deletion.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:245-261: select_recipient_key_index returns disabled keys
Verified at HEAD lines 245-261: the selector iterates recipient_identity.public_keys() and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no disabled_at check. validate_contact_request in crypto/validation.rs does gate on disabled keys, so a recipient with a disabled preferred DECRYPTION key gets returned anyway and the broadcast fails downstream with an opaque error instead of falling through to a usable ENCRYPTION key on the same identity. Beyond reliability, on the send-side this also means the wallet could encrypt the DIP-15 compact xpub to a revoked key whose private half may be compromised. Add `&& k.disabled_at().is_none()` to both branches so selection matches validation.
In `packages/rs-platform-wallet-ffi/src/dashpay_payment.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay_payment.rs:89-108: DashpayPaymentFFI derives Clone, Copy despite owning *mut c_char strings reclaimed by dashpay_payment_array_free
Verified at HEAD lines 89-108: DashpayPaymentFFI carries two heap-owned C strings (txid, memo — produced via CString::into_raw in cstring_or_null and reclaimed via CString::from_raw in dashpay_payment_array_free), yet the struct is `#[derive(Debug, Clone, Copy)]`. With Copy the compiler will silently shallow-duplicate the struct on any by-value rebinding inside this crate, and a subsequent free walk on the array (or a stray from_raw on the duplicate) would double-free the txid/memo allocations across the FFI boundary. Today's call sites are sound — the struct is built once, moved into Vec → Box<[T]> → Box::into_raw, and reclaimed exactly once — but the Copy derive removes the borrow-checker guardrail that normally prevents this class of bug at refactor time. Sibling FFI types in this crate that own heap pointers (ContactRequestFFI, WalletChangeSetFFI) deliberately omit Copy for exactly this reason. Drop Copy (and Clone if unneeded) on this struct. The cross-boundary contract is unchanged — Swift consumes by raw pointer.
…3, M3) Send side: - contact requests now carry the DIP-15 masked accountReference instead of a hardcoded 0: (version << 28) | (ASK28 ^ account). With the contract's unique index (ownerId, toUserId, accountReference), the constant 0 meant a superseding request after key rotation could never broadcast (duplicate-unique rejection) — the version bump is what makes re-keying possible. - Re-sending to a recipient with a tracked prior request unmasks the prior version and bumps it (saturating at the 4-bit max with a warning). Crypto helper fixes (research/06 §3 found both axes wrong): - HMAC input is now the 69-byte DIP-15 compact xpub (both reference clients agree), not the 107-byte DIP-14 encode(). - ASK28 extraction matches iOS dash-shared-core: digest bytes [28..32] big-endian >> 4. The reference clients disagree with each other here (Android: bytes [0..4] LE) — recipients must disregard the field per DIP-15, so the binding consumer is our own round-trip; we follow the Rust reference implementation and flag the divergence for a DIP clarification. - New unmask_account_reference recovers (version, account) for the sender. Receive side (DIP-15 "sender rotated their addresses"): - Sync ingest dedups by (sender, accountReference) instead of bare sender id: a known sender with a NEW reference is a rotation request and passes the guard (the old guard silently dropped it). - apply_rotated_incoming_request supersedes the tracked request (last-write-wins per pair; simultaneous multi-account rides acceptedAccounts later), clears payment_channel_broken — the recovery the flag's contract promises — and the sync pass tears down the stale external account so the build sweep re-registers it from the rotated xpub. Tests: ASK28 byte-order pin (fails on the old head-of-digest read), mask/unmask round-trip across version/account ranges, rotation re-key + broken-flag clear + pending-replace + stranger no-op. 223/223 lib + 9/9 workflow green.
Shared-secret-only callback on the existing host-signer table; the identity private key never crosses the ABI. EcdhProvider routing stays internal to platform-wallet so M4's implementation lands without wallet-API churn. One hook covers both send-side and decrypt-side ECDH.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Re-verified all 10 prior findings against worktree HEAD aabc21e; every one is STILL VALID — none of the three Swift example-app commits or the v3.1-dev merge touched the dashpay-correctness Rust/Swift hotspots. Carrying forward 7 blockers (append-only V001 violation, rejected-request persistence asymmetry, missing tombstone reader, transient-as-permanent channel breakage, sticky purpose_mismatch, unreachable broken-channel recovery, SwiftData wallet-deletion miss on the new payments cascade) and 3 suggestions (sync stop/start cleanup race, send-side disabled-key selection, Copy on FFI-owned C-strings). REQUEST_CHANGES.
🔴 7 blocking
3 additional finding(s) omitted (not in diff).
3 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs:109-131: record_rejected_contact_request drops incoming in memory but never persists the deletion
Verified at HEAD lines 109-131: line 116 calls `self.incoming_contact_requests.remove(sender_id)` but the returned `ContactChangeSet` (lines 127-129) only populates `cs.rejected`. The unified contacts-table writer in `rs-platform-wallet-storage/src/sqlite/schema/contacts.rs` only DELETEs incoming rows when `cs.removed_incoming` is non-empty, so the persisted `state='received'` row with its stale `incoming_request` blob stays in SQLite. Once `persister.load()` is wired up, the contacts reader re-buckets that row as an incoming request and `apply_changeset` re-inserts it — the explicitly-rejected request reappears in the UI. The in-memory mutation and the persisted delta are internally inconsistent. Emit a matching `ReceivedContactRequestKey { owner_id, sender_id: *sender_id }` into `cs.removed_incoming` alongside `cs.rejected`.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests tombstones are written but never restored on rehydration
Verified at HEAD: `managed_identity_from_entry` still hard-codes `rejected_contact_requests: Default::default()` at line 214. The writer at `contacts.rs:240` (`INSERT INTO rejected_contact_requests`) and the migration row at `V001:203` exist, but no `load_state` reader for the new table exists, and `apply_changeset` only handles live deltas — restored state is always empty. The recurring DashPay loop's tombstone-skip at `network/contact_requests.rs:396-404` therefore never fires after a restart, so a rejected contact request is resurrected on the first sweep and surfaces to the user again. Security framing: the on-platform document is immutable, so the local tombstone is the ONLY thing that suppresses a spammer's repeated contact request — a wipe-on-restart defeats the user's explicit reject. Add a per-wallet `load_state` reader for `rejected_contact_requests` and route its output into the `ContactChangeSet.rejected` synthesized during rehydration.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:711-729: Transient DAPI failures inside register_external_contact_account are classified as permanent
Verified at HEAD lines 711-729: `build_contact_accounts` treats ANY error from `register_external_contact_account` as permanent and calls `mark_contact_channel_broken`. `register_external_contact_account` performs a fresh `Identity::fetch` internally and wraps DAPI/network failures as `PlatformWalletError::InvalidIdentityData`. A single transient DAPI hiccup therefore permanently disables the payment channel; subsequent sweeps skip the contact via the `payment_channel_broken` filter, and recovery only fires if a superseding contactRequest arrives — but the established-contact ingest skip at line 389 makes that path unreachable. Combined, a transient network event bricks a channel forever, and a malicious or unreliable DAPI endpoint becomes a persistent availability attack against payments to a specific contact. Fix by either passing the pre-fetched identity into registration or scoping permanent-broken classification to genuinely non-recoverable failures (decrypt/decode, missing-key, key-type mismatch).
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:383-404: Established-contact ingest skip makes payment_channel_broken recovery unreachable
Verified at HEAD lines 383-404: the received-side ingest drops every doc whose sender is already in `established_contacts` (line 389) BEFORE consulting `accountReference`. `EstablishedContact::reestablish_preserving_metadata` exists precisely to clear `payment_channel_broken` when a fresh request flows in, and `collect_account_build_candidates` documents the recovery contract ("never retry a permanently-broken channel — wait for a superseding request which clears the flag on re-establish"). But no path reaches `reestablish_preserving_metadata` for an already-established sender from the sync sweep — `add_incoming_contact_request` is only called for new senders here, and the send-side guard at `state/managed_identity/contact_requests.rs:46-48` also returns early for established contacts. Once `payment_channel_broken` is set, it stays set forever. Either detect a new `accountReference` for the same sender and route through the reestablish path, or change the broken-channel policy to permit controlled retry.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:245-261: select_recipient_key_index returns disabled (revoked) keys — DIP-15 compact xpub encrypted to a key whose private half may be compromised
Verified at HEAD lines 245-261: the selector iterates `recipient_identity.public_keys()` and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no `disabled_at` check. `validate_contact_request` in `crypto/validation.rs` does gate on `disabled_at`, so the asymmetry creates both a reliability bug (an opaque downstream broadcast failure instead of falling through to a usable key) AND a real confidentiality exposure: on send, the wallet encrypts the 69-byte DIP-15 compact xpub (fingerprint‖chaincode‖pubkey — combined with `accountReference` lets the holder derive every receiving address on that account) to a key the recipient has explicitly revoked. Identity-key revocation is the on-platform mechanism for declaring "the private half of this key may be compromised". Add `&& k.disabled_at().is_none()` to both branches so selection matches validation. (Promoted from suggestion to blocking on the security-auditor confidentiality analysis.)
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3018-3035: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
Verified at HEAD: PHASE 1 (lines 3024-3034) iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional (`PersistentDashpayPayment.swift:98`), and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment (lines 3018-3023) explicitly states this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 (`save()` after `delete(identity)`), aborting before the wallet row is removed. The user's belief that they wiped DashPay data is wrong, and plaintext memo + counterparty id + amount + txid rows remain on disk. Add a `for payment in Array(identity.dashpayPayments) { backgroundContext.delete(payment) }` loop alongside the existing pre-deletions.
In `packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/dashpay_sync.rs:192-235: DashPaySyncManager thread cleanup unconditionally clears the cancel token — stop/start race enables use-after-free across FFI
Verified at HEAD lines 192-235: the spawned thread's cleanup at lines 230-232 writes `*guard = None` on loop exit regardless of which token the slot currently holds. If `stop()` cancels the old token and `start()` installs a fresh token before the old thread reaches cleanup, the old thread clears the NEW token — `background_cancel` is empty while a fresh sync thread is still running. `stop()`/`quiesce()` then become a no-op against that running thread. In this PR's Swift integration the persister and DashPay event callbacks close over an UnsafePointer<Context> allocated on the Swift side; calling `dashpay_sync_manager_destroy` (or the wallet manager's drop) after the visible token was cleared frees that context while the surviving thread continues invoking the callbacks against the freed pointer — a concrete use-after-free crossing the C ABI, reachable through normal start/stop/destroy controls (toggling tabs, login/logout) and widened by attacker-influenced network timing. Capture the spawned token and only clear the slot if it still holds the same token (`Arc::ptr_eq`), mirroring `ShieldedSyncManager`'s generation guard. (Upgraded from suggestion to blocking based on the security-auditor and codex-security cross-checks of the destroy/UAF path.)
…earch Decisive: no reference client (DashSync-iOS, dashj, dash-shared-core) ever implemented contactInfo — our implementation sets the de-facto convention. Adopts: DIP-15 child derivation (root/65536'+65537'/idx'), AES-256-ECB encToUserId, IV-prepended AES-256-CBC privateData, CBOR array [aliasName, note, displayHidden] per the deployed schema (which contradicts DIP-15 prose — table included), ≥2-contacts publish gate.
… part 1) The crypto core for DashPay contactInfo documents, following the conventions recorded in docs/dashpay/research/07 (no reference client ever implemented this doc type — this sets the de-facto wire format): - platform-encryption: AES-256-ECB encrypt/decrypt for the 32-byte encToUserId (two raw blocks, no IV/padding — DIP-15's own ECB soundness argument: the plaintext is a SHA-256 output and the key is single-purpose), plus IV-prepended AES-256-CBC helpers for privateData. Tests pin the ECB property (identical blocks encrypt identically) so a CBC-with-zero-IV regression can't slip in. - platform-wallet crypto/contact_info.rs: DIP-15 key derivation (rootEncryptionKey / 65536' / index' for encToUserId, / 65537' / index' for privateData — hardened children of the identity's registered ENCRYPTION key path), CBOR codec for the deployed schema's array shape [aliasName, note, displayHidden] with a 4th ignored padding element lifting tiny payloads to the schema's 48-byte ciphertext floor. Tests: key-derivation determinism + domain separation, CBOR round-trip incl. all-absent payload, full derive→encrypt→decrypt round-trip with schema bounds check.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Reconciliation against HEAD 440ffca: prior finding #6 (established-contact ingest skip) is FIXED by the new rotation path. Nine prior findings remain STILL VALID and the new G3 delta introduces two additional blockers — the send-side rotation-version lookup ignores established_contacts (forcing version=0 collisions after auto-establishment) and the receive-side rotation handler replays immutable historical requests as fresh rotations on every sweep, churning the external account. Total: 10 in-scope blockers. Overflow: 1 suggestion (DashpayPaymentFFI Copy) dropped due to budget.
🔴 5 blocking
2 additional finding(s) omitted (not in diff).
5 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:165-201: Send-side rotation version lookup ignores established_contacts — re-send after auto-establishment reuses version=0 and collides on the unique index
The new G3 rotation logic computes `previous_version` only from `managed.sent_contact_requests.get(recipient_identity_id)` (line 171). But establishment (`add_incoming_contact_request` line 175 and `apply_established_contact` line 372 in state/managed_identity/contact_requests.rs) explicitly removes the entry from `sent_contact_requests` and parks the prior outgoing request on `EstablishedContact.outgoing_request`. Once the reciprocal arrives and a sweep auto-establishes the pair, the next `send_contact_request` to that recipient sees `previous_version = None` and falls back to `version = 0`. With deterministic xpub/ECDH for the same (sender, recipient) and unchanged `account_index`, the PRF reproduces the same masked `account_reference` as the original sent request. The contract's unique index `($ownerId, toUserId, accountReference)` rejects the broadcast — the exact failure mode G3 was added to prevent. Fall back to `established_contacts[recipient].outgoing_request` (taking the max of both versions if both are present).
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:441-478: Historical contactRequest documents replay as fresh rotations every sync sweep
The rotation guard at line 451 only compares the incoming reference against the currently tracked reference (incoming map or established contact). contactRequest documents are immutable, and `fetch_received_contact_requests(identity_id, None)` (line 370) is unfiltered, so every sweep returns both the original v=0 and any rotated v=N documents. Within a single sweep, ingesting v=0 against an already-tracked v=N flips the established contact back to v=0 and queues a teardown (lines 472-478, then 517-528), and the next document in the same iteration flips it forward again. Across sweeps the same churn replays — the external account is torn down and rebuilt on every cycle, generating wasted DAPI traffic. Worse, if the freshest document falls outside the eventual paginated window (post-M3 growth), the contact can regress to the stale xpub. Compare by `created_at`/version monotonicity, not bare reference inequality: only apply rotation when the incoming request strictly supersedes the tracked one.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:292-308: select_recipient_key_index returns disabled (revoked) keys — DIP-15 compact xpub encrypted to a key whose private half may be compromised
Verified at HEAD lines 292-308: the selector iterates `recipient_identity.public_keys()` and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no `disabled_at` check. `validate_contact_request` does gate on `disabled_at`, so the send/receive interop rules are asymmetric. On send, the 69-byte DIP-15 compact xpub (fingerprint‖chaincode‖pubkey — combined with `accountReference` lets the holder derive every receiving address on that account) is encrypted to a key the recipient has explicitly revoked. Identity-key revocation is the on-platform mechanism for declaring 'this key's private half may be compromised'. Add `&& k.disabled_at().is_none()` to both branches so selection matches validation.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests tombstones are written but never restored on rehydration
`managed_identity_from_entry` still hard-codes `rejected_contact_requests: Default::default()` at line 214. The writer at `sqlite/schema/contacts.rs:240` (INSERT INTO rejected_contact_requests) and the V001 row exist, but there is no `load_state` reader for the new table, and `apply_changeset` only handles live deltas — restored state is always empty after restart. The recurring DashPay loop's tombstone-skip at `network/contact_requests.rs:457` therefore never fires after restart, so a rejected request is resurrected on the first sweep. Because the on-platform document is immutable, wiping the tombstone on restart defeats the user's explicit reject. Add a per-wallet `load_state` reader for `rejected_contact_requests` and route its output into the rehydration changeset / ManagedIdentity field.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3024-3034: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
PHASE 1 iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment (lines 3008-3023) explicitly documents that this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 `save()`, aborting before the wallet row is removed. The user believes their data was wiped; plaintext memo + counterparty id + amount + txid rows remain on disk. Pre-delete `identity.dashpayPayments` alongside the other children.
Network layer over the part-1 crypto core: - fetch_decrypted_contact_infos: query the owner's contactInfo docs (with the load-bearing ORDER BY $updatedAt — drive proves absence for bare secondary-index equality, same trap as the contact-request queries), derive each doc's keys from its own rootEncryptionKeyIndex/derivationEncryptionKeyIndex, decrypt encToUserId to find which contact it belongs to. The contact↔doc mapping is deliberately stateless — restore-from-seed recovers alias/note/hidden entirely from chain. - sync_contact_infos: step 3 of the recurring dashpay_sync pass; applies decrypted metadata onto established contacts through the new ManagedIdentity::set_contact_metadata (no-op when unchanged so recurring passes don't spam the persister). - set_contact_info_with_external_signer: local state first (works offline), then publish create-or-update through the put_document seam. Enforces DIP-15's privacy rule: with <2 established contacts the network write is deferred (logged), local state still lands. Fresh docs take the next sequential derivation index; updates reuse the existing doc's index + bump its revision. - FFI: platform_wallet_set_dashpay_contact_info_with_signer (same vtable-signer shape as the profile write). Follow-ups (part 3): persist alias/note/hidden across the FFI persister into SwiftData (ContactRequestFFI + Swift model columns), switch ContactDetailView off the UserDefaults meta store, and seam-level tests for the sync/publish paths via the recording SdkWriter. 226/226 lib tests green.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs (1)
109-130:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAlso emit
removed_incomingwith the rejection tombstone.Line 116 removes the incoming request from in-memory state, but the returned
ContactChangeSetonly carriesrejected. The reject path inpackages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rspersists this delta as-is, so restore/replay has no persisted signal to delete the old incoming request and can resurrect the rejected request after reload. Add the matchingReceivedContactRequestKeytocs.removed_incomingwhen the map entry is removed.Proposed fix
pub fn record_rejected_contact_request( &mut self, sender_id: &Identifier, account_reference: u32, document_id: Option<Identifier>, ) -> ContactChangeSet { let owner_id = self.id(); - self.incoming_contact_requests.remove(sender_id); + let removed_incoming = self.incoming_contact_requests.remove(sender_id).is_some(); let tombstone = RejectedContactRequest { owner_id, sender_id: *sender_id, account_reference, document_id, }; self.rejected_contact_requests .insert((*sender_id, account_reference), tombstone.clone()); let mut cs = ContactChangeSet::default(); + if removed_incoming { + cs.removed_incoming.insert(ReceivedContactRequestKey { + owner_id, + sender_id: *sender_id, + }); + } cs.rejected .insert((owner_id, *sender_id, account_reference), tombstone); cs }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs` around lines 109 - 130, In record_rejected_contact_request, after removing the entry from incoming_contact_requests you must also record that removal in the returned ContactChangeSet so the delta persists deletion; build a ReceivedContactRequestKey (owner_id, *sender_id, account_reference) and insert it into cs.removed_incoming before returning. This ensures the rejection tombstone is added to cs.rejected and the corresponding incoming entry is emitted via cs.removed_incoming for proper replay; locate record_rejected_contact_request, incoming_contact_requests, ContactChangeSet and Removed/removed_incoming to implement the insertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/dashpay/research/07-contactinfo-conventions.md`:
- Around line 25-28: The fenced code block showing the derivation paths lacks a
language tag; update the triple-backtick fence surrounding the lines starting
with "encToUserId key:" and "privateData key:" to include a plain text specifier
(e.g., ```text or ```plaintext) so the block is lint-compliant and renders
correctly.
In `@packages/rs-platform-wallet-ffi/src/contact_info.rs`:
- Around line 51-56: The code currently converts signer_handle to usize and back
to a pointer which can lose pointer provenance; instead capture a typed pointer
to VTableSigner before the worker hop and then unsafely dereference it inside
the closure. Concretely, in the block that calls
PLATFORM_WALLET_STORAGE.with_item and block_on_worker, replace the
signer_addr/usize roundtrip with a typed pointer (e.g., signer_ptr of type
*const VTableSigner derived from signer_handle) and inside the async closure
obtain the signer with unsafe { &*signer_ptr } when creating the &VTableSigner
used by the worker; leave block_on_worker and wallet access unchanged. Ensure
the symbol names referenced are signer_handle, signer_ptr, VTableSigner,
PLATFORM_WALLET_STORAGE.with_item, and block_on_worker.
---
Duplicate comments:
In
`@packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- Around line 109-130: In record_rejected_contact_request, after removing the
entry from incoming_contact_requests you must also record that removal in the
returned ContactChangeSet so the delta persists deletion; build a
ReceivedContactRequestKey (owner_id, *sender_id, account_reference) and insert
it into cs.removed_incoming before returning. This ensures the rejection
tombstone is added to cs.rejected and the corresponding incoming entry is
emitted via cs.removed_incoming for proper replay; locate
record_rejected_contact_request, incoming_contact_requests, ContactChangeSet and
Removed/removed_incoming to implement the insertion.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b106d317-7b25-48d6-884b-90c7c13f2b57
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (18)
docs/dashpay/SPEC.mddocs/dashpay/research/07-contactinfo-conventions.mdpackages/rs-platform-encryption/src/lib.rspackages/rs-platform-wallet-ffi/src/contact_info.rspackages/rs-platform-wallet-ffi/src/dashpay_profile.rspackages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet/Cargo.tomlpackages/rs-platform-wallet/src/lib.rspackages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rspackages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rspackages/rs-platform-wallet/src/wallet/identity/crypto/mod.rspackages/rs-platform-wallet/src/wallet/identity/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/contact_info.rspackages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rspackages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rspackages/rs-platform-wallet/src/wallet/identity/network/mod.rspackages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rspackages/rs-platform-wallet/tests/contact_workflow_tests.rs
✅ Files skipped from review due to trivial changes (2)
- packages/rs-platform-wallet/Cargo.toml
- docs/dashpay/SPEC.md
🚧 Files skipped from review as they are similar to previous changes (4)
- packages/rs-platform-wallet-ffi/src/lib.rs
- packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
- packages/rs-platform-wallet/src/lib.rs
- packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs
…part 3) Pipeline: ContactRequestFFI gains alias/note/is_hidden (established rows only, replicated onto both directions like the broken flag; CString lifecycle owned by free_contact_requests_ffi; layout asserts updated 152→176) → Swift persistence handler copies them onto three new additive PersistentDashpayContactRequest columns (lightweight migration) → ContactDetailView + ContactsView read them reactively off the @query rows and write through the new ManagedPlatformWallet.setDashPayContactInfo (KeychainSigner, same vtable shape as the profile write). "This device only" labels replaced; the UserDefaults meta store now only keeps the add-time DPNS hint. Verified on-sim: alias save → FFI → Rust set_contact_metadata → persister → both SwiftData rows carry the alias; the DIP-15 privacy gate correctly deferred the document publish at 1 established contact ("publish deferred ... local state updated"). KNOWN GAP (fix follows): in the deferred-publish window the metadata does NOT survive an app relaunch — contacts are not restored from local persistence at load (they re-derive from chain via the sync sweep), so the re-establish round writes alias=None back over the rows. Once the contactInfo doc publishes (≥2 contacts), relaunch durability comes from chain. Next commit restores contacts (incl. metadata) from SwiftData at load, which also makes contacts visible on offline launches.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
All 10 prior findings against 440ffca are STILL VALID at HEAD 3f707c2 — verified directly in the worktree. The new contactInfo delta (commits d78bf31 + 3f707c2) does not touch any of the prior hotspots, so the prior blockers carry forward unchanged. Two additional issues surfaced in the new contactInfo code (pagination cap of 100 docs driving both sync and derivation_index allocation; ENCRYPTION key selection without disabled_at check on the owner's own keys at contact_info.rs:313-320). The pagination issue is folded into the budget as it overlaps with derivation-index correctness; the contact_info disabled_at gap is folded into finding #10 since it is the same root-cause pattern. Budget filled with 10 blockers; 2 suggestion-level findings (DashpayPaymentFFI Copy/Clone, contactInfo pagination cap) dropped due to budget but recorded in out_of_scope_findings.
🔴 2 blocking
2 additional finding(s) omitted (not in diff).
8 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests tombstones are written but never restored on rehydration
Verified at HEAD: `managed_identity_from_entry` hard-codes `rejected_contact_requests: Default::default()` at line 214. The writer (INSERT INTO rejected_contact_requests) and the V001 row exist, but no `load_state` reader for the new table exists, and `apply_changeset` only handles live deltas — restored state is always empty after restart. The recurring DashPay loop's tombstone check at `network/contact_requests.rs:457` therefore never fires after a restart, so a rejected request is resurrected on the first sweep. Add a per-wallet `load_state` reader for `rejected_contact_requests` and route its output into the rehydrated `ManagedIdentity.rejected_contact_requests`.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3024-3034: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
Verified at HEAD lines 3024-3034: PHASE 1 iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment (lines 3005-3023) explicitly documents that this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 `save()`, aborting before the wallet row is removed. The user believes their data was wiped; plaintext memo + counterparty id + amount + txid rows remain on disk — a confidentiality regression for users handing off or disposing devices.
…ad (M3, part 4)
Closes the part-3 relaunch-durability gap: contacts were never
restored from local persistence — they re-derived from chain on the
first sync sweep, so (a) the Contacts UI was empty on offline
launches and (b) the re-establish round emitted alias=None over the
SwiftData rows, wiping contactInfo metadata during the DIP-15
deferred-publish window.
- IdentityRestoreEntryFFI gains a contacts array (reuses the
persist-side ContactRequestFFI shape; rows are load-allocation-
owned — Rust's persist-side destructor never runs on them).
- Swift assembles the per-identity PersistentDashpayContactRequest
rows into the load callback (payloads on scalarBuffers, metadata
strings on cStringBuffers, array on the new contactArrays list).
- Rust folds them into ManagedIdentity at load: direction pairs →
EstablishedContact (with alias/note/hidden + broken flag), single
rows → pending sent/incoming maps. Direct map inserts, no
persister rounds.
Verified on-sim: set alias → relaunch → SwiftData rows still carry
it AND the contact list renders it ("B, Bestie") after the first
sweep — the re-establish path now preserves metadata from the
restored in-memory contact.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified the checked-out worktree at 8037a47. All 10 prior blocking findings remain valid and in scope for this PR; no prior finding was resolved. CodeRabbit supplied no inline findings, and one lower-priority in-scope suggestion was omitted because the 10 blocking issues fill the comment budget.
🔴 10 blocking
1 additional finding(s) omitted (not in diff).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:167-180: Send-side rotation ignores established contacts
The resend path still derives `previous_version` only from `managed.sent_contact_requests`. Once reciprocal sync establishes the contact, that sent request is moved to `EstablishedContact.outgoing_request`, so a later send to the same recipient falls back to version 0. With the same sender, recipient, xpub, and account index, that can reproduce the original masked `accountReference` and collide with the DashPay contract unique index instead of creating a broadcastable rotation. The new load path also restores established contacts without repopulating `sent_contact_requests`, so a resend after relaunch hits the same state immediately.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:441-478: Historical requests replay as rotations
The receive-side rotation guard still compares only the incoming `accountReference` against the currently tracked reference. Because `fetch_received_contact_requests(identity_id, None)` is unfiltered and contactRequest documents are immutable, older v0 documents can be seen after a newer rotated document has advanced local state. Reprocessing the stale document as a rotation can replace the tracked request, tear down the external account, and then flip forward again on a later document or sweep; rotation acceptance needs a monotonic freshness check such as created time or decoded version, not bare reference inequality.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:806-826: Transient account-registration failures become permanent
`build_contact_accounts` still treats every `register_external_contact_account` error as permanent and marks `payment_channel_broken`. That callee performs additional network-backed identity/decryption work, so a transient DAPI or fetch failure can be converted into durable local broken-channel state. Future sweeps then skip the contact until the other party sends a superseding request; only genuinely non-recoverable decrypt, decode, missing-key, or key-type failures should take the permanent-broken path.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:292-308: Disabled encryption keys are still selectable
`select_recipient_key_index` still chooses the first DECRYPTION or ENCRYPTION ECDSA_SECP256K1 key without checking `disabled_at`, while receive-side validation rejects disabled keys. A sender can therefore encrypt the DIP-15 compact xpub to a key the recipient has revoked on-chain, which is the signal that the private half may be compromised. The same missing disabled-key filter remains in the contactInfo root encryption key selector at `contact_info.rs:313-320`, allowing owner-private alias/note/hidden metadata to be published under a revoked encryption key id.
In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V001__initial.rs:178-212: V001 was edited in place
`contacts.payment_channel_broken` and the `rejected_contact_requests` table are still added directly to V001, and there is no V002 migration. V001 already shipped, so existing v4.0.0-beta.4 databases will either fail refinery checksum validation or treat the old V001 as already applied and never create the new column/table. Runtime writes to the new broken-channel flag or rejected-request tombstone table then fail on upgraded wallets; these schema additions need an append-only migration.
In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs:109-130: Reject does not persist the incoming-row deletion
`record_rejected_contact_request` removes the incoming request from memory, but the returned `ContactChangeSet` only populates `cs.rejected`. The storage writer inserts rejected tombstones separately and only deletes live incoming rows when `cs.removed_incoming` is populated, so the stale `state='received'` contact row remains persisted. On the next load or contact restore, that stale row can reappear as a visible incoming request even though the user rejected it.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:3311-3417: Rejected tombstones are not restored
The new `restore_dashpay_contacts` bridge restores sent, incoming, and established contact rows, but `IdentityRestoreEntryFFI` has no rejected-tombstone input and this function never repopulates `ManagedIdentity.rejected_contact_requests`. SwiftData likewise treats rejected snapshots as delete-only visible-row operations. After relaunch, Rust has no suppression entry for the immutable rejected contactRequest, while stale incoming rows can still be restored or fetched again, so a rejected request can reappear despite the user's action.
In `packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs:50-79: purpose_mismatch survives hard validation errors
The type documents `purpose_mismatch` as true only when a key-purpose mismatch is the sole invalidity reason, and the sync code uses that flag to choose retry versus permanent broken-channel handling. However, `add_error` does not clear the flag, `add_purpose_error` sets it unconditionally, and `merge` ORs it. A request with both a hard key error and a purpose error can therefore be retried forever instead of being classified as permanently invalid.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3031-3047: Wallet deletion omits DashPay payment children
The SwiftData deletion workaround pre-deletes identity children with non-optional inverse relationships, but it still deletes only DPNS names, DashPay profile, and contact requests. `PersistentDashpayPayment.owner` is also non-optional and is reached through `PersistentIdentity.dashpayPayments`, so a wallet with DashPay payment history can hit the same SwiftData inverse-removal fatal during identity deletion. That aborts the wipe before the wallet row is removed and can leave payment metadata such as memo, counterparty id, amount, and txid on disk.
In `packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/dashpay_sync.rs:230-232: DashPay sync cleanup can clear a newer cancel token
The DashPay sync worker still unconditionally sets `background_cancel` to `None` when its loop exits. If `stop()` cancels token A, `start()` installs token B, and the old thread exits afterward, the old cleanup erases B while the new loop is still running. Later `stop()` or shutdown cannot find the live token, so FFI callback context can be freed while an untracked DashPay thread continues calling through Swift-owned callback pointers. The identity and shielded sync managers already use generation guards to avoid this race.
|
✅ DashSDKFFI.xcframework built for this PR.
SwiftPM (host the zip at a stable URL, then use): .binaryTarget(
name: "DashSDKFFI",
url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
checksum: "a35c2efe8eb96a98082ae78acfdcbf0d5672c1628268cdc2f4642481647a0911"
)Xcode manual integration:
|
…on, G8 real ciphertext, G9 contract cache - G6: the cfg(not(dashpay-contract)) fallback contract id held the DPNS id — corrected to the deployed DashPay id (latent foot-gun; dead code in default builds). - G7 (send half): the selected sender/recipient key pair now gates through validate_contact_request BEFORE any ECDH or broadcast — same validator the receive/accept paths use since M1. Warnings are logged; hard failures abort the send. Auto-accept stays deliberately dormant: it activates with M5 invitations behind the verify-gate acceptance criterion. - G8: the local sent ContactRequest now stores the real 96-byte encryptedPublicKey off the broadcast document instead of a zero placeholder, so the persisted/SwiftData row matches Platform. - G9: process-wide OnceLock cache for the bundled DashPay contract — replaces five per-call load_system_data_contract re-parses across the profile/contactInfo paths. 226/226 lib tests green.
…19 done G4 scoping found the M3 ECDH-only hook insufficient for watch-only: the friendship/receiving xpub derivations are hardened (seed-bound) on both send and accept, so true watch-only needs xpub-derivation hooks too (or one combined derive-DashPay-context hook). Recorded so the future slice starts from the real constraint, not the incomplete M3 note. Task 20 (live cross-client e2e) marked blocked-external with the manual-run note.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative review at af53ae0 (incremental over 8037a47). The latest delta — G6 fallback contract id, G7 pre-send key-pair validator, G8 real ciphertext capture, G9 process-wide contract cache — is small and well-scoped, and introduces no new blockers. G7 mitigates the worst-case security half of prior finding #10 on the send path (a disabled recipient key is now caught before ECDH/broadcast), but the underlying selectors still don't filter disabled_at and the contactInfo rootEncryptionKeyIndex selector is still ungated. The remaining nine prior blockers are unchanged at HEAD. Verified the send-side rotation lookup, the rejected-request changeset, the V001 in-place edits (no V002 exists), and the unconditional DashPay sync cancel-token clear by reading the worktree at HEAD. Recommending REQUEST_CHANGES.
🔴 4 blocking
1 additional finding(s) omitted (not in diff).
6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V001__initial.rs:178-212: V001 migration edited in place — refinery upgrade from v4.0.0-beta.4 will not pick up new column / table
Verified at HEAD: `contacts.payment_channel_broken` (line 189) and `CREATE TABLE rejected_contact_requests` (lines 203-212) are added directly into V001, and no V002 migration exists anywhere under `packages/rs-platform-wallet-storage/migrations/` (globbed `V*.rs` returns only V001). V001 (without these additions) shipped in v4.0.0-beta.4, and the storage README enforces append-only migrations because refinery checksums each migration in `refinery_schema_history`. Against an existing beta.4 DB this either aborts on divergent checksum or silently skips V001 as already-applied — neither the new column nor the new table will exist. The first runtime write touching `payment_channel_broken` (`mark_contact_channel_broken`) or `INSERT INTO rejected_contact_requests` (G5 tombstone) then fails at SQLite, disabling the PR's payment-channel-broken safety state and the G5 rejection tombstone on every upgraded wallet. Add a V002 migration: `ALTER TABLE contacts ADD COLUMN payment_channel_broken INTEGER` plus a separate `CREATE TABLE rejected_contact_requests (...)`.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:3311-3417: Rejected tombstones are written but never restored on rehydration
`restore_dashpay_contacts` restores sent, incoming, and established rows from the FFI restore spec, but `IdentityRestoreEntryFFI` exposes no rejected-tombstone field — there is no C struct or count/pointer pair for tombstones in the FFI surface at all. The Swift side persists rejected snapshots as write-only deletes on visible rows, and the SQLite reconstruction helper defaults `rejected_contact_requests` to empty. After relaunch, Rust has no suppression entry for the immutable rejected contactRequest, and the sync-loop tombstone check at contact_requests.rs:500 never fires — a rejected request resurfaces on the first sweep. Because the on-platform document is immutable, wiping the tombstone on restart defeats the user's explicit reject and re-exposes them to suppression-bypass by an unwanted sender. Add a per-wallet load reader for `rejected_contact_requests`, surface the rows via a new `rejected_tombstones` field on `IdentityRestoreEntryFFI`, and rehydrate `ManagedIdentity.rejected_contact_requests` from that input.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3037-3048: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
PHASE 1 iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment explicitly documents that this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 `save()`, aborting before the wallet row is removed. The user believes their data was wiped; plaintext memo + counterparty id + amount + txid rows remain on disk — a confidentiality regression for users handing off or disposing devices.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs:301-308: Disabled encryption keys are still selectable — contactInfo rootEncryptionKeyIndex and contactRequest selector both miss disabled_at
G7's pre-send validator at `contact_requests.rs:127-138` now gates the contactRequest send path against `validate_contact_request`, which checks `disabled_at`, closing the worst-case 'encrypt DIP-15 compact xpub to a revoked recipient key' confidentiality path. Two residual gaps remain in this PR's scope: (1) `contact_info.rs:301-308` still picks the owner's first ECDSA_SECP256K1 ENCRYPTION key for `rootEncryptionKeyIndex` with no `disabled_at` filter and no equivalent validator gate. The published contactInfo doc references that key id, and if an attacker has the revoked root private material the public id + derivation index lets them derive the same contactInfo AES keys and decrypt the owner's private alias/note/hidden metadata — a real confidentiality exposure on the contactInfo path. (2) `contact_requests.rs:335-351` (`select_recipient_key_index`) still picks the first matching key without `disabled_at`; when a recipient has [disabled DECRYPTION + live ENCRYPTION], the new G7 gate now hard-fails the send instead of falling back to the live ENCRYPTION key — a behavioral regression introduced by this delta. Add `&& k.disabled_at().is_none()` to both selectors so contactInfo is safe and the contactRequest send path picks the live key cleanly.
- Spec §9 "As-built notes": the resolver FFI accepts ECDSA_HASH160 (binding disambiguated by expected-key-data length), the backfill self-check is a canonical-path-from-indices check (seedless) backed by the sign-time MF-2 binding rather than a pubkey re-derivation, and Phase 2 step 6 (the irreversible scalar deletion) + the funded-testnet acceptance gate are NOT done -- the carried scalar + legacy signer stay as the safety net, so the shipped change is reversible and non-lockout. - SwiftExampleApp/CLAUDE.md: note the in-app testnet faucet at Wallet > Receive for funding a wallet during testing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntity-key-scalar-elimination # Conflicts: # docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md
… path (+ tests) The 4-lens code review found no defects; this folds its should-fix + polish items and closes the test gaps the funded UAT couldn't run. Fixes: - Run the breadcrumb backfill OFF the main actor: scheduleBackfillIdentityKeyBreadcrumbs dispatches on the serial queue, so the @mainactor unlock path no longer blocks on the Keychain scan + serial-queue SwiftData work. The handler is marked @unchecked Sendable (its mutable state is serial-queue-confined). - canSign's resolver branch gains the wid.count == 32 guard that resolveIdentityKeyContext enforces, so it no longer reports a corrupt-walletId row as signable. - Gate the resolver attempt to ECDSA key types (0/2); a non-ECDSA derivable key skips straight to the stored scalar with no spurious resolver call or IDENTITY_SIGN_FALLBACK log. - The backfill saves only when a row actually changed (written > 0). - Add SignWithMnemonicResolverError.pubkeyMismatch = 11 (mirrors the Rust tag). Tests: - Rust: the binding rejects a malformed expected-key length (neither 20 nor 33) and a wrong HASH160, both fail-closed (platform-wallet-ffi 126/126). - Swift: IdentityKeyBreadcrumbTests pins the backfill's written/failed/skipped outcomes (the deletion-gate signal) via a new items-injection seam, and resolveIdentityKeyContext incl. the wid-guard / no-breadcrumb cases (4/4 via swift test). resolveIdentityKeyContext lowered fileprivate -> internal for @testable access. build_ios.sh --target sim + SwiftExampleApp: BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion Review finding (thepastaclaw): registering a username from the DashPay tab's "Register a username" CTA left the prompt visible. The author comment claimed it "hides reactively" via the persisted dpnsName, but the Rust register path only upserts PersistentDPNSName relationship rows — it never writes the scalar PersistentIdentity.dpnsName / mainDpnsName the prompt (and the header) read. So hasName stayed false and the identity stayed in usernameResolvedIds until the next identity switch or app-foreground re-check. Fix: RegisterNameView's onRegistered now persists the new label onto the identity's scalar dpnsName and drops it from usernameResolvedIds, so the prompt hides on the next render and the header shows the new name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…act URI Review finding (thepastaclaw): parse_dashpay_contact_uri decodes the dapk query value from an untrusted DIP-15 URI (QR scan / pasted deep link) with bs58::decode and no upper bound. A valid ECDSA blob is KEY_BLOB_LEN (38) bytes (~52 base58 chars) and wrong-length blobs are rejected anyway, but only after the decoder allocates ~0.73 x len bytes — so a hostile multi-megabyte dapk forces a large allocation per scan/paste before any structural validation runs. Fix: reject dapk longer than 128 chars before decoding. Adds a regression test (parse_contact_uri_caps_oversized_dapk_before_decoding) asserting an oversized dapk is rejected up front and a valid-length one still parses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uest Review finding (thepastaclaw): the send_contact_request map_err collapsed every dash_sdk::Error variant (network/transport, signing, broadcast rejection, consensus, proof verification) into a single text-formatted InvalidIdentityData. The neighboring put_document already uses PlatformWalletError::Sdk(e) to preserve the variant. This PR's payment_channel_broken policy classifies transient vs. permanent failures from machine-readable error structure, and the UI otherwise shows "invalid identity data" for what is actually a network timeout. Switch to .map_err(PlatformWalletError::Sdk). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rly return Review finding (thepastaclaw): the three dashpay-sync scalar getters (is_running / is_syncing / last_sync_unix_seconds) checked the out-pointer but only wrote *out on the success path; the stale-handle branch (unwrap_option_or_return!) returned without assigning, leaving the Swift caller reading uninitialized stack contents. Define each out-slot (false / 0) immediately after the pointer check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p budget Review finding (thepastaclaw): the per-sweep page-budget comment covered only the same-$createdAt-cluster case. The wallet caller rewinds each sweep's lower bound by SYNC_OVERLAP_MS (10 min) for clock-skew safety, which widens the cursor-stall case into a 10-minute window an attacker could saturate. Document that interaction explicitly; the recovery (a persisted StartAfter document-id continuation cursor) is already named as the follow-up. Memory stays bounded regardless (oldest-first, budget-capped). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review finding (thepastaclaw): the doc claimed the contact_account_label is "persisted via an established changeset entry — the same path as the broken-channel flag", contradicting the SQLite backend (which deliberately drops it) and the field's own design (EstablishedContact::contact_account_label resets to None on every (re-)establish/rotation and is re-derived from the incoming request to stay fresh against rotated key material). The label is in-memory + re-derived-on-sweep, NOT durably persisted across restart; correct the doc to match. The payments.rs read-back test asserts the in-memory surfacing, not SQLite persistence, so no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addressed the automated review findings (20 threads resolved)Per-thread inline replies were blocked by an existing pending review on this PR, so summarizing here — each of the 20
Verification: Swift The architecture questions in the pending review are left for the dedicated derive-sign-destroy / identity-key scalar-elimination work. |
…estroy) Proves the load-bearing path the sim UAT was meant to cover, with no GUI: signIdentityKeyOnDemand resolves the breadcrumb, the mnemonic resolver reads the seed from WalletStorage, the key is derived on demand at the DIP-9 path, the MF-2 binding confirms it reproduces the row's pubkey, and a valid 65-byte signature is produced -- plus a wrong-path case proving the binding rejects before signing. signIdentityKeyOnDemand lowered fileprivate -> internal for @testable. 2/2 via swift test (mac slice). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The current head still leaves several exported FFI out-parameters undefined on fallible paths, and the latest delta changes an exported signing function's C ABI in place while adding a nullable expected-key binding that can be accidentally skipped. Five prior findings were verified as fixed in the new commits. CodeRabbit has no actionable inline findings in this run.
🔴 8 blocking | 🟡 4 suggestion(s)
Findings not posted inline (6)
These findings could not be anchored to the current diff, but they are still part of this review.
- [BLOCKING]
packages/rs-platform-wallet-ffi/src/dashpay.rs:175-183: Initialize contact-request array before sync can fail —platform_wallet_sync_contact_requestsvalidatesout_array, then performs fallible wallet lookup and async Platform sync before writing it. On a stale handle or sync error, the exported C ABI returns with the caller'sContactRequestHandleArray { handles, count }untouched, while `platform_wa... - [BLOCKING]
packages/rs-platform-wallet-ffi/src/dashpay.rs:483-492: Initialize sent-request array before query can fail —platform_wallet_fetch_sent_contact_requestshas the same owned-array ABI contract as the sync path, but identity decoding, stale wallet handles, and async fetch errors all return before*out_arrayis assigned. A C/Swift caller that frees the out array on a non-OK result can pass an uninitiali... - [BLOCKING]
packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:326-329: Initialize profile output before signer profile update can fail —platform_wallet_create_or_update_dashpay_profile_with_signerchecksout_profile, then can return during identifier decoding, optional C-string decoding, wallet lookup, or async create/update before writing*out_profile.DashPayProfileFFIowns C-string pointers released by `dashpay_profile... - [BLOCKING]
packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:133-147: Do not change an exported signing symbol's ABI in place — At the previous reviewed commit,dash_sdk_sign_with_mnemonic_resolver_and_pathtooknetworkfollowed immediately by the output-buffer arguments. This commit insertsexpected_key_dataandexpected_key_data_lenbefore those outputs while keeping the same#[no_mangle]symbol name. Existing... - [SUGGESTION]
packages/rs-platform-wallet-ffi/src/established_contact.rs:160-167: Null the note out-parameter before fallible lookup —established_contact_get_notewrites*out_noteonly after handle lookup, note lookup, andCString::newall succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's pointer slot unchanged even though the success path returns an owned C string. Set the out pointer to... - [SUGGESTION]
packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:164-170: Validate expected-key pointer and length together before signing — The new expected-key binding only runs whenexpected_key_datais non-null andexpected_key_data_len > 0. That means a malformed FFI call withexpected_key_data = nulland a nonzero length silently skips the binding and signs anyway, even though the API documents null/0 as the intentional wa...
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:175-183: Initialize contact-request array before sync can fail
`platform_wallet_sync_contact_requests` validates `out_array`, then performs fallible wallet lookup and async Platform sync before writing it. On a stale handle or sync error, the exported C ABI returns with the caller's `ContactRequestHandleArray { handles, count }` untouched, while `platform_wallet_contact_request_handle_array_free` treats those fields as Rust-owned boxed-slice metadata. Initialize the out struct to the empty sentinel before any fallible work so every error path is cleanup-safe.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:483-492: Initialize sent-request array before query can fail
`platform_wallet_fetch_sent_contact_requests` has the same owned-array ABI contract as the sync path, but identity decoding, stale wallet handles, and async fetch errors all return before `*out_array` is assigned. A C/Swift caller that frees the out array on a non-OK result can pass an uninitialized pointer/count pair back into Rust. Publish `ContactRequestHandleArray::empty()` immediately after the pointer check.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:175-183: Initialize contact-request array before sync can fail
`platform_wallet_sync_contact_requests` validates `out_array`, then performs fallible wallet lookup and async Platform sync before writing it. On a stale handle or sync error, the exported C ABI returns with the caller's `ContactRequestHandleArray { handles, count }` untouched, while `platform_wallet_contact_request_handle_array_free` treats those fields as Rust-owned boxed-slice metadata. Initialize the out struct to the empty sentinel before any fallible work so every error path is cleanup-safe.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:483-492: Initialize sent-request array before query can fail
`platform_wallet_fetch_sent_contact_requests` has the same owned-array ABI contract as the sync path, but identity decoding, stale wallet handles, and async fetch errors all return before `*out_array` is assigned. A C/Swift caller that frees the out array on a non-OK result can pass an uninitialized pointer/count pair back into Rust. Publish `ContactRequestHandleArray::empty()` immediately after the pointer check.
In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:326-329: Initialize profile output before signer profile update can fail
`platform_wallet_create_or_update_dashpay_profile_with_signer` checks `out_profile`, then can return during identifier decoding, optional C-string decoding, wallet lookup, or async create/update before writing `*out_profile`. `DashPayProfileFFI` owns C-string pointers released by `dashpay_profile_ffi_free`, so the raw ABI should not require callers to pre-zero an owning out struct before a fallible call. Match the sibling profile getters and initialize it to `DashPayProfileFFI::empty()` before fallible work.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:326-329: Initialize profile output before signer profile update can fail
`platform_wallet_create_or_update_dashpay_profile_with_signer` checks `out_profile`, then can return during identifier decoding, optional C-string decoding, wallet lookup, or async create/update before writing `*out_profile`. `DashPayProfileFFI` owns C-string pointers released by `dashpay_profile_ffi_free`, so the raw ABI should not require callers to pre-zero an owning out struct before a fallible call. Match the sibling profile getters and initialize it to `DashPayProfileFFI::empty()` before fallible work.
In `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:133-147: Do not change an exported signing symbol's ABI in place
At the previous reviewed commit, `dash_sdk_sign_with_mnemonic_resolver_and_path` took `network` followed immediately by the output-buffer arguments. This commit inserts `expected_key_data` and `expected_key_data_len` before those outputs while keeping the same `#[no_mangle]` symbol name. Existing generated headers, binary Swift modules, or external C callers compiled against the old prototype will shift every argument after `network`, causing Rust to read the old output pointer as expected key data and write through the wrong out pointers. Keep the old symbol as a compatibility wrapper and add a new versioned symbol for the key-bound form.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:164-170: Validate expected-key pointer and length together before signing
The new expected-key binding only runs when `expected_key_data` is non-null and `expected_key_data_len > 0`. That means a malformed FFI call with `expected_key_data = null` and a nonzero length silently skips the binding and signs anyway, even though the API documents null/0 as the intentional way to skip the check. Enforce the pointer/length invariant up front so cross-language call mistakes fail closed.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:133-147: Do not change an exported signing symbol's ABI in place
At the previous reviewed commit, `dash_sdk_sign_with_mnemonic_resolver_and_path` took `network` followed immediately by the output-buffer arguments. This commit inserts `expected_key_data` and `expected_key_data_len` before those outputs while keeping the same `#[no_mangle]` symbol name. Existing generated headers, binary Swift modules, or external C callers compiled against the old prototype will shift every argument after `network`, causing Rust to read the old output pointer as expected key data and write through the wrong out pointers. Keep the old symbol as a compatibility wrapper and add a new versioned symbol for the key-bound form.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:164-170: Validate expected-key pointer and length together before signing
The new expected-key binding only runs when `expected_key_data` is non-null and `expected_key_data_len > 0`. That means a malformed FFI call with `expected_key_data = null` and a nonzero length silently skips the binding and signs anyway, even though the API documents null/0 as the intentional way to skip the check. Enforce the pointer/length invariant up front so cross-language call mistakes fail closed.
In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:160-167: Null the note out-parameter before fallible lookup
`established_contact_get_note` writes `*out_note` only after handle lookup, note lookup, and `CString::new` all succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's pointer slot unchanged even though the success path returns an owned C string. Set the out pointer to null before the lookup so error paths are deterministic and cleanup-safe.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:160-167: Null the note out-parameter before fallible lookup
`established_contact_get_note` writes `*out_note` only after handle lookup, note lookup, and `CString::new` all succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's pointer slot unchanged even though the success path returns an owned C string. Set the out pointer to null before the lookup so error paths are deterministic and cleanup-safe.
…n-destroy)
The verified 32-byte ECDSA scalar no longer crosses Rust -> FFI -> Swift.
Discovery still verifies key ownership via validate_private_key_bytes, but the
scalar is now derived, verified, and dropped -- only the DIP-9 breadcrumb
(wallet_id, identity_index, key_index) is emitted. The client derives the key
on demand from the Keychain seed at the breadcrumb path when it signs.
- changeset: drop KeyWithBreadcrumb.verified_scalar + IdentityKeyEntry.private_key
(derive Debug now that there is no secret to redact)
- FFI: drop IdentityKeyEntryFFI.{private_key, private_key_is_some}; recompute the
layout guard 224 -> 184; from_entry copy + free-scrub gone
- discovery / identity_ops: stop carrying the scalar through add_keys
- Swift: delete storeCarriedIdentityKey, IdentityKeyEntrySnapshot.privateKey, the
FFI-decode copy + the post-callback scrub loop
The legacy Keychain-scalar fallback signer (lookupIdentityPrivateKey / ffiSign)
is KEPT so keys already materialized on existing installs still sign -- only the
carried (and stored-anew) scalar is removed, which is non-lockout. New keys are
resolver-only: they derive on demand from the seed at the persisted breadcrumb.
Gated on the headless resolver-sign integration test (fa1098c): it stays green
after the deletion, proving the resolver path signs through the Swift layer with
no stored scalar. Verified: platform-wallet 314 + FFI tests; Swift
IdentityResolverSign 2/2 + IdentityKeyBreadcrumb 4/4 + 30 Identity tests, 0
failures; SwiftExampleApp sim BUILD SUCCEEDED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/dashpay-m1-sync-correctness Eliminate the carried identity-key scalar (derive-sign-destroy) + its headless resolver-sign integration test. No FFI/changeset secret; client derives keys on demand from the Keychain seed at the breadcrumb path. Legacy Keychain-scalar fallback signer retained for already-materialized keys (non-lockout).
Phase 2 step 6 is done (carried scalar + FFI fields removed, layout guard 224->184) with the legacy Keychain-scalar fallback retained (non-lockout). The un-runnable funded-testnet zero-fallback UAT was substituted with the headless resolver-sign integration test. Docs only, no behavior change (no test needed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental review at head e20d730. The scalar-elimination refactor is a security-positive removal of the carried 32-byte ECDSA scalar, but it broke platform-wallet-storage compilation by leaving stale private_key struct-literal sites in the lib and two integration tests (confirmed via cargo check -p platform-wallet-storage --tests — E0560). It also shrinks the exported #[repr(C)] IdentityKeyEntryFFI from 224 to 184 bytes while reusing the same callback ABI. All six prior FFI findings remain STILL VALID at head — the delta did not touch dashpay.rs, dashpay_profile.rs, established_contact.rs, or sign_with_mnemonic_resolver.rs. Carrying forward 4 prior blockers + 2 prior suggestions, plus 3 new compile-error blockers and 1 new ABI-shrink blocker. Overflow: 1 suggestion (secrets-scan allowlist cleanup) dropped for budget.
🔴 13 blocking | 🟡 4 suggestion(s)
Findings not posted inline (7)
These findings could not be anchored to the current diff, but they are still part of this review.
- [BLOCKING]
packages/rs-platform-wallet-ffi/src/dashpay.rs:175-183: Initialize contact-request array before sync can fail — STILL VALID prior finding, unchanged at head.platform_wallet_sync_contact_requestsvalidatesout_array, then performs fallible wallet lookup and async Platform sync before writing it. On a stale handle or Platform/DAPI error, the exported C ABI returns non-OK with the caller's `ContactReques... - [BLOCKING]
packages/rs-platform-wallet-ffi/src/dashpay.rs:483-492: Initialize sent-request array before query can fail — STILL VALID prior finding, unchanged at head.platform_wallet_fetch_sent_contact_requestshas the same owned-array ABI contract as the sync path:read_identifier, stale wallet handles, and async fetch errors all return before*out_arrayis written. Because the fetch depends on remote Platfo... - [BLOCKING]
packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:326-329: Initialize profile output before signer profile update can fail — STILL VALID prior finding, unchanged at head.platform_wallet_create_or_update_dashpay_profile_with_signerchecksout_profileandsigner_handle, then can return duringread_identifier, threedecode_opt_c_strcalls, wallet lookup, or the async create/update before writing*out_profile.... - [BLOCKING]
packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:133-147: Do not change an exported signing symbol's ABI in place — STILL VALID prior finding, unchanged at head.dash_sdk_sign_with_mnemonic_resolver_and_pathgained two arguments (expected_key_data,expected_key_data_len) inserted betweennetworkand the output-buffer arguments while keeping the same#[no_mangle]symbol name. Any generated header, pre... - [BLOCKING]
packages/rs-platform-wallet-ffi/src/identity_persistence.rs:236-347: IdentityKeyEntryFFI struct shrunk in place — same ABI-break class as the signing symbol — New in this delta. The scalar-elimination refactor removes the trailingprivate_key_is_some: boolandprivate_key: [u8; 32]fields from the exported#[repr(C)]IdentityKeyEntryFFIand shrinks the compile-time guard from 224 to 184 bytes, while keeping the same `on_persist_identity_keys_fn... - [SUGGESTION]
packages/rs-platform-wallet-ffi/src/established_contact.rs:160-167: Null the note out-parameter before fallible lookup — STILL VALID prior finding, unchanged at head.established_contact_get_notewrites*out_noteonly after handle lookup, note lookup, andCString::newall succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's pointer slot unchanged even though the success path ret... - [SUGGESTION]
packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:164-170: Validate expected-key pointer and length together before signing — STILL VALID prior finding, unchanged at head. The expected-key binding at line 257 only runs whenexpected_key_datais non-null ANDexpected_key_data_len > 0. Two malformed cross-language marshaling shapes silently skip the binding and sign anyway: (a)expected_key_data = nullwith a nonzer...
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs:70-82: platform-wallet-storage no longer compiles — stale IdentityKeyEntry.private_key field
The scalar-elimination commit (930c100c) removed `private_key` from `IdentityKeyEntry`, but `IdentityKeyWire::into_entry` still constructs the struct with `private_key: None` at line 80. `cargo check -p platform-wallet-storage --tests` fails with `E0560: struct IdentityKeyEntry has no field named private_key`. Because the surrounding block is `#[cfg(any(test, feature = "__test-helpers"))]`, the plain `cargo check -p platform-wallet-storage` still passes — this is how the refactor slipped past local verification. The same file also has `private_key: Some(zeroize::Zeroizing::new([0xC7; 32]))` at line 222 and `restored.private_key.is_none()` at line 229 inside the `wire_round_trip_drops_signing_scalar` test that need to be removed together.
In `packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs:225-236: sqlite_persist_roundtrip test constructs IdentityKeyEntry with removed field
This integration test still initializes `IdentityKeyEntry` with `private_key: None` after the field was removed. Blocks `cargo test -p platform-wallet-storage`.
In `packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs:310-318: sqlite_structural_hardening test constructs IdentityKeyEntry with removed field
Same compile-breaking pattern as sqlite_persist_roundtrip.rs — `private_key: None` no longer resolves against the current `IdentityKeyEntry`. Must be dropped for `cargo test -p platform-wallet-storage` to build.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:175-183: Initialize contact-request array before sync can fail
STILL VALID prior finding, unchanged at head. `platform_wallet_sync_contact_requests` validates `out_array`, then performs fallible wallet lookup and async Platform sync before writing it. On a stale handle or Platform/DAPI error, the exported C ABI returns non-OK with the caller's `ContactRequestHandleArray { handles, count }` untouched. `platform_wallet_contact_request_handle_array_free` interprets those fields as Rust-owned boxed-slice metadata and calls `Box::from_raw` — a C/Swift caller that runs unconditional cleanup on a non-OK result frees uninitialized memory. Because the error path can be triggered by remote peer behavior, this is an FFI-boundary memory-corruption primitive. Publish the empty sentinel immediately after `check_ptr!`.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:483-492: Initialize sent-request array before query can fail
STILL VALID prior finding, unchanged at head. `platform_wallet_fetch_sent_contact_requests` has the same owned-array ABI contract as the sync path: `read_identifier`, stale wallet handles, and async fetch errors all return before `*out_array` is written. Because the fetch depends on remote Platform responses, an attacker positioned to answer that endpoint can force the error path deterministically. Publish `ContactRequestHandleArray::empty()` immediately after the pointer check, before `read_identifier`.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:175-183: Initialize contact-request array before sync can fail
STILL VALID prior finding, unchanged at head. `platform_wallet_sync_contact_requests` validates `out_array`, then performs fallible wallet lookup and async Platform sync before writing it. On a stale handle or Platform/DAPI error, the exported C ABI returns non-OK with the caller's `ContactRequestHandleArray { handles, count }` untouched. `platform_wallet_contact_request_handle_array_free` interprets those fields as Rust-owned boxed-slice metadata and calls `Box::from_raw` — a C/Swift caller that runs unconditional cleanup on a non-OK result frees uninitialized memory. Because the error path can be triggered by remote peer behavior, this is an FFI-boundary memory-corruption primitive. Publish the empty sentinel immediately after `check_ptr!`.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:483-492: Initialize sent-request array before query can fail
STILL VALID prior finding, unchanged at head. `platform_wallet_fetch_sent_contact_requests` has the same owned-array ABI contract as the sync path: `read_identifier`, stale wallet handles, and async fetch errors all return before `*out_array` is written. Because the fetch depends on remote Platform responses, an attacker positioned to answer that endpoint can force the error path deterministically. Publish `ContactRequestHandleArray::empty()` immediately after the pointer check, before `read_identifier`.
In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:326-329: Initialize profile output before signer profile update can fail
STILL VALID prior finding, unchanged at head. `platform_wallet_create_or_update_dashpay_profile_with_signer` checks `out_profile` and `signer_handle`, then can return during `read_identifier`, three `decode_opt_c_str` calls, wallet lookup, or the async create/update before writing `*out_profile`. `DashPayProfileFFI` owns three C-string pointers (display_name / public_message / avatar_url) released by `dashpay_profile_ffi_free` via `platform_wallet_string_free`. A caller that runs cleanup after non-OK frees uninitialized pointer fields. Zero-initialize before any fallible step.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:326-329: Initialize profile output before signer profile update can fail
STILL VALID prior finding, unchanged at head. `platform_wallet_create_or_update_dashpay_profile_with_signer` checks `out_profile` and `signer_handle`, then can return during `read_identifier`, three `decode_opt_c_str` calls, wallet lookup, or the async create/update before writing `*out_profile`. `DashPayProfileFFI` owns three C-string pointers (display_name / public_message / avatar_url) released by `dashpay_profile_ffi_free` via `platform_wallet_string_free`. A caller that runs cleanup after non-OK frees uninitialized pointer fields. Zero-initialize before any fallible step.
In `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:133-147: Do not change an exported signing symbol's ABI in place
STILL VALID prior finding, unchanged at head. `dash_sdk_sign_with_mnemonic_resolver_and_path` gained two arguments (`expected_key_data`, `expected_key_data_len`) inserted between `network` and the output-buffer arguments while keeping the same `#[no_mangle]` symbol name. Any generated header, pre-compiled Swift module, or external C caller compiled against the previous prototype will shift every argument after `network`: Rust reads the old `out_signature` pointer as `expected_key_data`, uses the old `out_signature_capacity` as `expected_key_data_len`, and writes signature bytes through the wrong pointers — undefined behavior on every call, and the pubkey-binding guard runs against caller-buffer contents. Keep the old symbol as a compatibility wrapper (bind-null internally) and export the new key-bound form under a versioned symbol name, or delete the old symbol entirely if this branch is the only consumer.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:164-170: Validate expected-key pointer and length together before signing
STILL VALID prior finding, unchanged at head. The expected-key binding at line 257 only runs when `expected_key_data` is non-null AND `expected_key_data_len > 0`. Two malformed cross-language marshaling shapes silently skip the binding and sign anyway: (a) `expected_key_data = null` with a nonzero length, and (b) a non-null pointer with `expected_key_data_len == 0`. The API docs treat null/0 as the intentional way to opt out, but paired-inconsistent shapes now bypass the very guardrail this PR added. Fail closed by enforcing the pointer/length invariant up front in the argument validation block.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:133-147: Do not change an exported signing symbol's ABI in place
STILL VALID prior finding, unchanged at head. `dash_sdk_sign_with_mnemonic_resolver_and_path` gained two arguments (`expected_key_data`, `expected_key_data_len`) inserted between `network` and the output-buffer arguments while keeping the same `#[no_mangle]` symbol name. Any generated header, pre-compiled Swift module, or external C caller compiled against the previous prototype will shift every argument after `network`: Rust reads the old `out_signature` pointer as `expected_key_data`, uses the old `out_signature_capacity` as `expected_key_data_len`, and writes signature bytes through the wrong pointers — undefined behavior on every call, and the pubkey-binding guard runs against caller-buffer contents. Keep the old symbol as a compatibility wrapper (bind-null internally) and export the new key-bound form under a versioned symbol name, or delete the old symbol entirely if this branch is the only consumer.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:164-170: Validate expected-key pointer and length together before signing
STILL VALID prior finding, unchanged at head. The expected-key binding at line 257 only runs when `expected_key_data` is non-null AND `expected_key_data_len > 0`. Two malformed cross-language marshaling shapes silently skip the binding and sign anyway: (a) `expected_key_data = null` with a nonzero length, and (b) a non-null pointer with `expected_key_data_len == 0`. The API docs treat null/0 as the intentional way to opt out, but paired-inconsistent shapes now bypass the very guardrail this PR added. Fail closed by enforcing the pointer/length invariant up front in the argument validation block.
In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/identity_persistence.rs:236-347: IdentityKeyEntryFFI struct shrunk in place — same ABI-break class as the signing symbol
New in this delta. The scalar-elimination refactor removes the trailing `private_key_is_some: bool` and `private_key: [u8; 32]` fields from the exported `#[repr(C)]` `IdentityKeyEntryFFI` and shrinks the compile-time guard from 224 to 184 bytes, while keeping the same `on_persist_identity_keys_fn` callback contract. Any caller compiled against the previous header will still stride the `upserts_ptr` array as 224-byte rows and read the removed trailing fields off the end (or into the next row), causing memory corruption or misparsed callback data. This is the same class of concern as the `dash_sdk_sign_with_mnemonic_resolver_and_path` in-place ABI change — the in-repo Swift wrapper was updated in lockstep but any external cbindgen-consumer compiled against the previous layout is broken silently. Preserve the removed fields as deprecated zero-filled padding until a versioned callback slot is added, or bump/rename the callback signature so old and new layouts cannot be confused.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/identity_persistence.rs:236-347: IdentityKeyEntryFFI struct shrunk in place — same ABI-break class as the signing symbol
New in this delta. The scalar-elimination refactor removes the trailing `private_key_is_some: bool` and `private_key: [u8; 32]` fields from the exported `#[repr(C)]` `IdentityKeyEntryFFI` and shrinks the compile-time guard from 224 to 184 bytes, while keeping the same `on_persist_identity_keys_fn` callback contract. Any caller compiled against the previous header will still stride the `upserts_ptr` array as 224-byte rows and read the removed trailing fields off the end (or into the next row), causing memory corruption or misparsed callback data. This is the same class of concern as the `dash_sdk_sign_with_mnemonic_resolver_and_path` in-place ABI change — the in-repo Swift wrapper was updated in lockstep but any external cbindgen-consumer compiled against the previous layout is broken silently. Preserve the removed fields as deprecated zero-filled padding until a versioned callback slot is added, or bump/rename the callback signature so old and new layouts cannot be confused.
In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:160-167: Null the note out-parameter before fallible lookup
STILL VALID prior finding, unchanged at head. `established_contact_get_note` writes `*out_note` only after handle lookup, note lookup, and `CString::new` all succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's pointer slot unchanged even though the success path returns an owned C string that must be freed with `platform_wallet_string_free`. A caller that unconditionally frees `*out_note` on the shape of the success path hits use-after-free of whatever the slot held pre-call. Null the pointer before the lookup.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:160-167: Null the note out-parameter before fallible lookup
STILL VALID prior finding, unchanged at head. `established_contact_get_note` writes `*out_note` only after handle lookup, note lookup, and `CString::new` all succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's pointer slot unchanged even though the success path returns an owned C string that must be freed with `platform_wallet_string_free`. A caller that unconditionally frees `*out_note` on the shape of the success path hits use-after-free of whatever the slot held pre-call. Null the pointer before the lookup.
Deep review — multi-agent, adversarially verifiedThis review ran 10 independent finder passes over the full diff (line-by-line per area, removed-behavior audit, cross-file trace, reuse/simplification/efficiency/altitude/conventions), then adversarially verified every candidate with an independent verifier agent instructed to refute it against the checkout at the PR head ( Headline cluster — account-reference rotation is broken end-to-end: F1 (sender-side reference frozen after the first rotation → unique-index rejection on every later rotation, plus a restore-from-seed variant that pins the oldest reference), F2 (receiver-side teardown is in-memory only and the pending-crypto queue is not restored at load → a restart resurrects the rotated-away xpub and payments derive addresses the contact no longer watches), and F6 (drain/sweep race can drop a rotated ciphertext). Together these deserve a dedicated rotation test pass before merge. Also notable: F3 (a mid-round Refuted during verification (not posted inline):
Process notes:
A follow-up PR fixing the confirmed issues is being prepared. 🤖 Generated with Claude Code Correctness findings (14)F1-rotation-freeze — CONFIRMED (high) —
|
Design / conventions findings (11)C3-swift-keytype-mirror — CONFIRMED (medium) —
|
… + wallet rollbacks
Review raised that `let _ = <Result>` is fragile: it relies on today's
implementation (an infallible persister) staying infallible, and silently
drops a genuine error if that ever changes.
- managed_identity_{send,accept}_contact_request: return the persist
`Result` from the mutator closure and propagate it through the FFI result
(`unwrap_result_or_return!` → `From<PersistenceError>`). A store failure now
surfaces to the caller for any persister on this handle path, present or
future, instead of being discarded.
- managed_identity_ignore_contact_sender: `ignore_sender` returns a
`ContactChangeSet` (not a `Result`) — there is no error to surface, so make
the discard an explicit `drop(...)` with a comment rather than a misleading
`let _`.
- wallet rollback paths (load.rs unwind loop, wallet_lifecycle.rs ×4): the
`let _ = wm.remove_wallet(...)` sites dropped a `Result<_, WalletError>`.
These are best-effort cleanup that must keep returning the original error,
so log the rollback failure via `tracing::warn!` instead of silently
dropping it — an inconsistent unwind is now observable.
No behavior change on the success path. platform-wallet + platform-wallet-ffi
build; contact FFI tests pass; fmt + clippy (--all-features) clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Cumulative + incremental reconciliation at head 95d6714. The latest delta (e20d730..95d6714, three files) is a clean quality fix — it propagates previously-swallowed Results in the contact FFI mutators and adds tracing::warn! on best-effort wallet-manager rollback paths — and introduces no new defects. However, all 10 prior findings from the e20d730 review remain STILL_VALID at head; none of the affected files were touched by this push. Three blockers are storage-crate compile breaks (IdentityKeyEntry.private_key field removed but still referenced at three sites — verified: cargo check -p platform-wallet-storage --tests fails with E0560), and five blockers plus two suggestions are FFI trust-boundary defects on the DashPay/signing surface newly added by this PR (uninitialized owned out-parameters, in-place #[no_mangle] ABI changes, and a pubkey-binding guard that malformed pointer/length pairs can bypass).
🔴 8 blocking | 🟡 2 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs:70-82: platform-wallet-storage test build broken — stale IdentityKeyEntry.private_key field
STILL_VALID at 95d6714f (unanimous across all six reviewers). The scalar-elimination refactor (930c100c) removed `private_key` from `IdentityKeyEntry` (verified: `packages/rs-platform-wallet/src/changeset/changeset.rs` no longer defines that field), but `IdentityKeyWire::into_entry` at line 80 still constructs the struct with `private_key: None` under `#[cfg(any(test, feature = "__test-helpers"))]`. Plain `cargo check -p platform-wallet-storage` passes because the cfg-gated block is skipped, but `cargo check -p platform-wallet-storage --tests` fails with `E0560: struct IdentityKeyEntry has no field named private_key` at this line — which is how the regression slipped past. The in-file test `wire_round_trip_drops_signing_scalar` at lines 222 and 229 also references the removed field and must be updated in the same edit. Because this is the durable persistence crate the FFI + Swift SDK ride on, the compile break silently disables the storage-side regression tests that assert 'no signing scalar reaches disk' — the exact invariant this PR advertises.
In `packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs:225-236: sqlite_persist_roundtrip constructs IdentityKeyEntry with removed private_key field
STILL_VALID at 95d6714f. Line 235 still initializes `IdentityKeyEntry { …, private_key: None }` against a struct that no longer has that field. Blocks `cargo test -p platform-wallet-storage` — this is the primary regression harness for the on-disk shape of persisted identity-key rows. Drop `private_key: None` in the same edit as the identity_keys.rs fix.
In `packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs:310-318: sqlite_structural_hardening constructs IdentityKeyEntry with removed private_key field
STILL_VALID at 95d6714f. Line 317 still initializes `IdentityKeyEntry { …, private_key: None }`. Same E0560 compile break as the other two sites; blocks the structural-hardening test target that pins the row-boundary and blob-vs-column invariants for the SQLite persistence layer.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:170-185: platform_wallet_sync_contact_requests: initialize owned out_array before fallible sync
STILL_VALID at 95d6714f (verified verbatim: line 175 does `check_ptr!(out_array)`, then a fallible `PLATFORM_WALLET_STORAGE.with_item` lookup + async `sync_contact_requests().await`, then writes `*out_array` only on the success path at line 183). On a stale wallet handle or Platform/DAPI error, the function returns non-OK and the caller's `ContactRequestHandleArray { handles, count }` is left with whatever bytes it held before the call. `platform_wallet_contact_request_handle_array_free` treats those fields as Rust-owned boxed-slice metadata and calls `Box::from_raw` on `handles` sized by `count` — so a Swift/C caller that runs unconditional cleanup on non-OK (the standard `defer`-style pattern) frees uninitialized pointer bits. The error path is remotely reachable (any DAPI failure), giving an externally-driven FFI memory-corruption primitive. Publish `ContactRequestHandleArray::empty()` immediately after `check_ptr!`, before any fallible step.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:478-494: platform_wallet_fetch_sent_contact_requests: initialize owned out_array before fallible fetch
STILL_VALID at 95d6714f. Same owned-array ABI hazard as `platform_wallet_sync_contact_requests`: between `check_ptr!(out_array)` and `*out_array = …`, three fallible steps (identifier decoding, stale wallet handle lookup, async `sent_contact_requests(&id).await`) can return non-OK. Because the fetch depends on a remote Platform response, an attacker positioned to answer or disrupt the endpoint forces the error path deterministically; a caller's cleanup-on-error path then feeds `Box::from_raw` an uninitialized pointer/count pair. Publish `ContactRequestHandleArray::empty()` right after the pointer check.
In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:314-333: platform_wallet_create_or_update_dashpay_profile_with_signer: initialize owned out_profile before fallible steps
STILL_VALID at 95d6714f. `check_ptr!(out_profile)` and `check_ptr!(signer_handle)` at lines 326-327 are followed by `read_identifier`, three `decode_opt_c_str` calls, a wallet-storage handle lookup, and the async signer-driven create/update — any of which can return non-OK before `*out_profile` is written. `DashPayProfileFFI` owns three heap C-string pointers (display_name / public_message / avatar_url) released by `dashpay_profile_ffi_free` via `platform_wallet_string_free` (i.e. `CString::from_raw` on each). A caller running unconditional cleanup after non-OK feeds `CString::from_raw` arbitrary bit patterns from the caller's stack — an unbounded-free primitive at the FFI trust boundary. Zero-initialize before any fallible step (sibling profile getters already do this).
In `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:133-147: Silent in-place ABI change on exported dash_sdk_sign_with_mnemonic_resolver_and_path
STILL_VALID at 95d6714f. The `#[no_mangle] pub unsafe extern "C"` signing symbol grew two arguments (`expected_key_data: *const u8`, `expected_key_data_len: usize`) inserted between `network` and the output-buffer arguments while keeping the same exported symbol name. Any caller compiled against the previous prototype (generated cbindgen header, pre-compiled Swift binary framework, external C consumer) shifts every argument after `network`: Rust reads the old `out_signature` pointer as `expected_key_data`, the old `out_signature_capacity` as `expected_key_data_len`, then writes signature bytes through the wrong pointers — undefined behavior on every call, and the new pubkey-binding guard runs against caller-buffer contents instead of the intended key material. Version the symbol (e.g. `_v2` / `_with_expected_key`) and keep the old symbol as a compatibility wrapper that binds null internally, or delete the old name so mismatched links fail loudly.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:164-172: Enforce paired expected_key_data pointer/length invariant up front
STILL_VALID at 95d6714f. The pubkey-binding downstream (line 257) only fires when `expected_key_data` is non-null AND `expected_key_data_len > 0`. Two malformed cross-language marshaling shapes silently skip the binding and produce a signature anyway: (a) `expected_key_data = null` with `expected_key_data_len > 0`, and (b) non-null pointer with `expected_key_data_len == 0`. The API docs treat null/0 as the intentional opt-out, so paired-inconsistent shapes are contract violations that bypass the very impersonation-resistance guard this PR added. Fail closed by enforcing the pointer/length invariant in the argument-validation block before any resolve/derivation work.
In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/identity_persistence.rs:236-348: IdentityKeyEntryFFI shrunk in place (224→184 bytes) under unchanged callback contract
STILL_VALID at 95d6714f (compile-time guard at line 347 now pins `size_of::<IdentityKeyEntryFFI>() == 184`, down from 224). The scalar-elimination refactor removed the trailing `private_key_is_some: bool` and `private_key: [u8; 32]` fields from the exported `#[repr(C)] IdentityKeyEntryFFI` while keeping the same `on_persist_identity_keys_fn` callback contract, symbol name, and callback slot. Any Swift/C consumer compiled against the previous 224-byte header will still stride the `upserts_ptr` array as 224-byte rows: subsequent rows are misparsed (reading `contract_bounds_kind` from the middle of the next row's `identity_id`), and per-row callbacks read the removed `private_key_is_some` byte from adjacent memory — memory corruption or callback misparse driven by every ordinary identity-key persist. Version the callback (e.g. `set_persist_identity_keys_callback_v2` / `persistIdentityKeysV2`) or preserve the removed fields as deprecated zero-filled padding for one release cycle so mixed-version linkage fails at load rather than silently corrupts.
In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169: established_contact_get_note: null out_note before fallible lookup
STILL_VALID at 95d6714f. `established_contact_get_note` writes `*out_note` only after handle lookup, note-presence check, and `CString::new` all succeed. Stale handles, missing notes (`contact.note == None`), and interior-NUL failures leave the caller's pointer slot unchanged, even though the success path returns an owned C-string that must be released with `platform_wallet_string_free` (which is `CString::from_raw`). A Swift caller with the common `defer { platform_wallet_string_free(ptr) }` pattern after unwrapping OK hits use-after-free / arbitrary-free of whatever the slot held pre-call. Null the pointer immediately after `check_ptr!`.
Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer
Bring the branch up to v4.1-dev, which bumps rust-dashcore to afcff156:
key-wallet now impls `Zeroize` + `Drop` on `ExtendedPrivKey`, and
`extended_public_key` moved into the new `ExtendedPubKeySigner` subtrait.
Conflicts resolved (take base's rust-dashcore, afcff156):
- Cargo.toml / Cargo.lock: base's afcff156 rev.
- packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs: reconcile the
derive-sign-destroy zeroization with the upstream change.
- `WipingXprv` REMOVED — it was a local workaround for `ExtendedPrivKey`
having no `Drop`, which key-wallet now provides (dashcore PR #833, added
specifically for this signer). The bare value self-wipes on every path.
- `WipingSecretKey` KEPT — the `secp256k1::SecretKey` copy at the two sign
sites is the one key intermediate #833 cannot cover (`SecretKey` is an
upstream type with no `Zeroize`, only `non_secure_erase()`), so the RAII
guard remains the only way to close its `?`-early-return / panic leak
window. Preserves the "no key bytes survive the trait boundary" invariant.
- `extended_public_key` moved into an `impl ExtendedPubKeySigner` block
(dashcore #839).
Bump ripple + follow-through:
- packages/rs-platform-wallet-ffi/src/dashpay.rs: import `ExtendedPubKeySigner`
for `receiving_xpub` (the method moved off `Signer`).
- packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs: drop its
own now-redundant `WipingXprv`, relying on `ExtendedPrivKey`'s upstream Drop.
rs-sdk-ffi + platform-wallet-ffi build; 300 + 8 tests pass (incl. the 12
resolver-signer tests); full-workspace build green; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… references The identity-key scalar-elimination (930c100) removed `private_key` from `IdentityKeyEntry`, but never propagated to platform-wallet-storage, leaving the crate uncompilable (`error[E0560]: struct IdentityKeyEntry has no field named private_key`). The default `cargo build --workspace` doesn't build this crate, so it slipped through until CI's full run — caught here. - `identity_keys.rs::into_entry`: stop initializing the removed field. - `wire_round_trip_drops_signing_scalar` → `wire_round_trip_preserves_watch_only_fields`: the test constructed an entry with a live scalar and asserted the wire drops it. There is no scalar field anymore, so that premise is gone — the "no key material at rest outside the keychain" guarantee is now enforced at the type level (the struct simply has no such field). The test keeps the watch-only round-trip coverage (wallet_id, derivation_indices). - `sqlite_persist_roundtrip.rs` / `sqlite_structural_hardening.rs`: drop the field from their `IdentityKeyEntry` literals. - `secrets_scan.rs`: remove the three now-dead allowlist needles that whitelisted the removed `private_key` expressions, tightening the guardrail. Red before (crate didn't compile) → green after: platform-wallet-storage builds; all suites pass (incl. secrets-scan); clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ta pointer/length The sign-with-resolver derived-key binding only fires when `expected_key_data` is non-null AND `expected_key_data_len > 0`. Two malformed cross-language marshaling shapes — a null pointer with a non-zero length, or a real pointer with length 0 — silently skipped the binding and produced a signature anyway, bypassing the impersonation-resistance guard this path exists to provide. The API treats `(null, 0)` as the intentional opt-out, so a mismatched pair is a contract violation: enforce the pointer/length invariant up front and fail closed (`SIGN_WITH_RESOLVER_ERR_NULL_POINTER`) rather than signing unbound. Regression test `inconsistent_expected_key_data_pointer_length_fails_closed` pins both shapes: ✖ before the check (skips binding, signs, rc=0) → ✔ after (rc=-1, no signature). Would have caught this in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Incremental+cumulative review at 78fa916. The latest delta (738f1ee→78fa9166) is a narrow storage-crate fix that drops the stale IdentityKeyEntry.private_key field references and prunes now-dead secrets-scan allowlist needles — resolving prior-1/2/3 (compile breaks in platform-wallet-storage). No new latest-delta findings. Prior-4 through prior-10, all in rs-platform-wallet-ffi, are untouched by this delta and remain STILL_VALID at head — carried forward: four uninitialized owned-out-parameter FFI paths, two in-place ABI changes on exported symbols/repr(C) structs, and one fail-open pointer/length pairing gap in the mnemonic-resolver signing entry point.
🔴 5 blocking | 🟡 2 suggestion(s)
Prior Finding Reconciliation
- FIXED: platform-wallet-storage no longer compiles — stale IdentityKeyEntry.private_key field in schema/identity_keys.rs::into_entry — FIXED at 78fa916. packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs no longer initializes
private_keyinIdentityKeyWire::into_entry; the companion pinning test was renamed towire_round_trip_preserves_watch_only_fields, and the 'no signing scalar at rest' invariant is now enforced at the type level (the struct has no such field). Sonnet5 independently reproducedcargo check -p platform-wallet-storage --testsexiting 0. - FIXED: sqlite_persist_roundtrip test constructs IdentityKeyEntry with removed private_key field — FIXED at 78fa916. packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs no longer includes
private_key: None,in the tc007 literal; test target compiles. - FIXED: sqlite_structural_hardening test constructs IdentityKeyEntry with removed private_key field — FIXED at 78fa916. packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs no longer includes
private_key: None,in the mismatch-rejected literal; the same commit also tightenedsecrets_scan.rsby removing three now-dead allowlist needles, hardening (not weakening) the guardrail. - STILL_VALID: [carried-forward prior-4] platform_wallet_sync_contact_requests leaves *out_array uninitialized on failure
- STILL_VALID: [carried-forward prior-5] platform_wallet_fetch_sent_contact_requests leaves *out_array uninitialized on failure
- STILL_VALID: [carried-forward prior-6] platform_wallet_create_or_update_dashpay_profile_with_signer leaves *out_profile uninitialized on failure
- STILL_VALID: [carried-forward prior-7] Silent in-place ABI change on exported dash_sdk_sign_with_mnemonic_resolver_and_path
- STILL_VALID: [carried-forward prior-8] IdentityKeyEntryFFI shrunk in place (224→184 bytes) under unchanged callback contract
- STILL_VALID: [carried-forward prior-10] Enforce paired expected_key_data pointer/length invariant up front — fails open
- STILL_VALID: [carried-forward prior-9] established_contact_get_note: null *out_note before fallible lookup
New Findings In Latest Delta
None.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:171-185: [carried-forward prior-4] platform_wallet_sync_contact_requests leaves *out_array uninitialized on failure
Verified at head 78fa9166 (verbatim unchanged since 738f1eed). `check_ptr!(out_array)` at line 175 only null-checks the pointer; `*out_array` is written exactly once at line 183, after two fallible steps (`with_item` wallet-handle lookup at 177-180, async `sync_contact_requests().await`) that can early-return via `unwrap_option_or_return!` / `unwrap_result_or_return!` at 181-182. On any Platform-side or handle failure the caller's `ContactRequestHandleArray { handles, count }` retains whatever pre-call bytes it held. `platform_wallet_contact_request_handle_array_free` (this file, 143-158) treats those fields as a Rust-owned boxed slice and drives `Box::from_raw`; its `array.handles.is_null()` guard only catches null pointers, not garbage-non-null bit patterns. A Swift/C caller using the common `defer { …_free(&array) }` cleanup pattern after a non-OK result feeds `Box::from_raw` uninitialized bits — an externally reachable heap-corruption primitive over any Platform sync failure. Sibling FFI (`dpns.rs`, `dashpay_payment.rs`) already publishes an empty sentinel before fallible work.
Suggested fix:
check_ptr!(out_array);
unsafe { *out_array = ContactRequestHandleArray::empty() };
let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| {
let identity = wallet.identity().clone();
block_on_worker(async move { identity.sync_contact_requests().await })
});
let result = unwrap_option_or_return!(option);
let list = unwrap_result_or_return!(result);
unsafe { *out_array = ContactRequestHandleArray::from_requests(list) };
PlatformWalletFFIResult::ok()
}
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:478-494: [carried-forward prior-5] platform_wallet_fetch_sent_contact_requests leaves *out_array uninitialized on failure
Verified at head 78fa9166 (unchanged since 738f1eed). Same owned-array hazard as prior-4: between `check_ptr!(out_array)` at line 483 and `*out_array = …` at line 492, three fallible steps run (identifier decoding at 484, wallet-handle lookup, async `sent_contact_requests(&id).await`), each of which can early-return without writing the output. Because the query depends on a remote Platform response, an adversary that can disrupt the endpoint or feed malformed identifier bytes can deterministically force the error path, after which a caller's cleanup path feeds `Box::from_raw` uninitialized bits. Same fix as prior-4: publish `ContactRequestHandleArray::empty()` right after the pointer check.
Suggested fix:
check_ptr!(out_array);
unsafe { *out_array = ContactRequestHandleArray::empty() };
let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) });
let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| {
let identity = wallet.identity().clone();
block_on_worker(async move { identity.sent_contact_requests(&id).await })
});
let result = unwrap_option_or_return!(option);
let list = unwrap_result_or_return!(result);
unsafe { *out_array = ContactRequestHandleArray::from_requests(list) };
PlatformWalletFFIResult::ok()
}
In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:314-368: [carried-forward prior-6] platform_wallet_create_or_update_dashpay_profile_with_signer leaves *out_profile uninitialized on failure
Verified at head 78fa9166 (unchanged since 738f1eed). `check_ptr!(out_profile)` / `check_ptr!(signer_handle)` at 326-327 are followed by `read_identifier` (329), three `decode_opt_c_str` calls (331-333), a wallet-storage handle lookup (343), and the async signer-driven create/update — any of which can return non-OK before `*out_profile = DashPayProfileFFI::from_profile(&profile)` at 367. `DashPayProfileFFI` owns three heap C-string pointers (display_name / public_message / avatar_url) released by `dashpay_profile_ffi_free` via `CString::from_raw`. A caller running unconditional cleanup after non-OK feeds `CString::from_raw` arbitrary bit patterns from the caller's stack — an unbounded-free primitive at the FFI trust boundary. Sibling profile getters in this same file already zero-initialize before fallible work; write `*out_profile = DashPayProfileFFI::empty()` immediately after the pointer checks so every subsequent early-return leaves the slot in a safely-freeable state.
Suggested fix:
check_ptr!(out_profile);
check_ptr!(signer_handle);
unsafe { *out_profile = DashPayProfileFFI::empty() };
In `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:119-135: [carried-forward prior-7] Silent in-place ABI change on exported dash_sdk_sign_with_mnemonic_resolver_and_path
Verified at head 78fa9166 (unchanged since 738f1eed). The `#[no_mangle] pub unsafe extern "C"` signing symbol grew two arguments (`expected_key_data: *const u8` at position 8, `expected_key_data_len: usize` at position 9) inserted between `network` and the output pointers while retaining its exported name. In-tree Swift callers (`KeychainSigner.swift`) were updated in the same commit, but C symbols carry no ABI version: any consumer linking against a pre-compiled xcframework or an external C consumer built against the previous prototype will silently reinterpret every argument after `network` — Rust reads the old `out_signature` pointer as `expected_key_data`, and so on — an arbitrary read/write primitive under the caller's control at a signing entry point. CLAUDE.md's own iOS troubleshooting notes explicitly call out stale-binary risk after merges. Version the symbol (e.g. `_v2` / `_with_expected_key`) or keep the old name as a compatibility shim that binds a null/0 expected key internally, so a stale caller fails to link rather than mis-marshaling into a signing function.
In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/identity_persistence.rs:236-348: [carried-forward prior-8] IdentityKeyEntryFFI shrunk in place (224→184 bytes) under unchanged callback contract
Verified at head 78fa9166. The compile-time guard at line 347 (`const _: [u8; 184] = [0u8; std::mem::size_of::<IdentityKeyEntryFFI>()];`) pins the new 184-byte layout, down from the pre-refactor 224. The scalar-elimination refactor removed the trailing `private_key_is_some: bool` and `private_key: [u8; 32]` fields from the exported `#[repr(C)] IdentityKeyEntryFFI` while keeping the `on_persist_identity_keys_fn` callback contract, symbol name, and slot unchanged. In-tree Swift regenerates its header via cbindgen at build time, so it is fine — the risk is external: any consumer with a pre-generated 224-byte header or a pre-built xcframework strides the `upserts_ptr` array in 224-byte increments over a Rust-side 184-byte stride. Every row after the first is misparsed (reading `contract_bounds_kind` from the middle of the next row's `identity_id`), and the last row reads past the allocation — memory corruption driven by every ordinary identity-key persist under mixed-version linkage. Version the callback (`set_persist_identity_keys_callback_v2` / `persistIdentityKeysV2`) so mixed-version linkage fails at load rather than silently corrupts.
In `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:152-160: [carried-forward prior-10] Enforce paired expected_key_data pointer/length invariant up front — fails open
Verified at head 78fa9166 (argument validation at 152-160 unchanged; binding guard at line 245 verified). The pubkey-binding guard `if !expected_key_data.is_null() && expected_key_data_len > 0` only fires when both fields are consistently set. Two malformed marshaling shapes silently skip the impersonation-resistance binding and still produce a signature: (a) `expected_key_data = null` with `expected_key_data_len > 0`, and (b) non-null pointer with `expected_key_data_len == 0`. The docstring treats `(null, 0)` as the intentional opt-out, so paired-inconsistent shapes are contract violations that bypass the very guard this PR added. Since this parameter exists specifically to prevent signing under a wrong/stale derived key, a mis-marshaled caller argument pair should fail closed with `SIGN_WITH_RESOLVER_ERR_NULL_POINTER`, not silently disable the security check.
Suggested fix:
if mnemonic_resolver_handle.is_null()
|| wallet_id_bytes.is_null()
|| derivation_path_cstr.is_null()
|| out_signature.is_null()
|| out_signature_len.is_null()
|| (data.is_null() && data_len > 0)
|| (expected_key_data.is_null() && expected_key_data_len > 0)
|| (!expected_key_data.is_null() && expected_key_data_len == 0)
{
return fail(SIGN_WITH_RESOLVER_ERR_NULL_POINTER);
}
In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169: [carried-forward prior-9] established_contact_get_note: null *out_note before fallible lookup
Verified at head 78fa9166 (unchanged since 738f1eed). `established_contact_get_note` writes `*out_note = c_str.into_raw()` at line 167 only after handle lookup (162), note-presence (164-165), and `CString::new` (166) all succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's `*mut c_char` slot unchanged, even though the success path returns an owned C-string that must be released with `platform_wallet_string_free` (`CString::from_raw`). A Swift caller using the common `defer { platform_wallet_string_free(ptr) }` pattern after unwrapping OK hits use-after-free / arbitrary-free of whatever the slot held pre-call. The in-repo Swift wrapper does zero-init before the call so this is currently safe in practice, but the FFI function should honor the defensive 'always safe to free' contract used elsewhere in this crate by nulling the pointer immediately after `check_ptr!`.
Suggested fix:
check_ptr!(out_note);
unsafe { *out_note = std::ptr::null_mut() };
let option =
ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone());
let option = unwrap_option_or_return!(option);
let note = unwrap_option_or_return!(option);
let c_str = unwrap_result_or_return!(std::ffi::CString::new(note));
unsafe { *out_note = c_str.into_raw() };
PlatformWalletFFIResult::ok()
}
Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer
There was a problem hiding this comment.
Code Review
Reconciliation of the incremental delta 78fa916..bb02c41: the single-commit fix cleanly resolves prior-10 (fail-closed on inconsistent expected_key_data pointer/length pairs at sign_with_mnemonic_resolver.rs:168-170, pinned by a new regression test). Five prior findings (prior-4/5/6/7/9) remain STILL_VALID at head and are carried forward. Prior-8 is dropped as OUTDATED: base v4.1-dev already has IdentityKeyEntryFFI at 184 bytes, so the 224→184 shrink narrative described an intermediate within-branch state that never reaches the merged diff. Two new PR-introduced FFI layout growths were found in-scope (IdentityEntryFFI 208→224, ContactRequestFFI 144→184 under unchanged persistence callback slots), plus one narrow safety suggestion on the new binding check's slice construction.
🔴 6 blocking | 🟡 2 suggestion(s)
Only the 3 latest-delta/new findings were posted as inline comments because GitHub can only anchor comments on current diff lines; the 5 carried-forward prior findings are included below and remain part of this review.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:171-185: platform_wallet_sync_contact_requests leaves *out_array uninitialized on every failure path
Reconciled STILL_VALID at bb02c416 (unchanged since 78fa9166). `check_ptr!(out_array)` at line 175 only null-checks the pointer; `*out_array` is written exactly once at line 183, after two fallible steps — `PLATFORM_WALLET_STORAGE.with_item` handle lookup and the async `sync_contact_requests().await` — either of which can early-return via `unwrap_option_or_return!` / `unwrap_result_or_return!` without touching the caller's slot. `ContactRequestHandleArray` is heap-owned; the paired `platform_wallet_contact_request_handle_array_free` (lines 143-158) reconstructs a boxed slice via `Box::from_raw` when `handles` is non-null and `count != 0`, guarded only against null. A caller who follows the common cleanup-on-error pattern (deferred `..._free`) after a non-OK result feeds `Box::from_raw` whatever bytes the caller's `ContactRequestHandleArray` slot happened to hold — an FFI-boundary heap-corruption primitive whenever a Platform sync fails. In-tree Swift call sites happen to zero-initialize and guard the free, but the C ABI contract should not require every consumer to know that convention. Publish `ContactRequestHandleArray::empty()` right after the pointer check, matching the pattern already used by sibling FFI (`dpns.rs`, `dashpay_payment.rs`).
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay.rs:478-494: platform_wallet_fetch_sent_contact_requests leaves *out_array uninitialized on failure
Reconciled STILL_VALID at bb02c416 (unchanged). Same owned-array hazard as the sync-side finding: between `check_ptr!(out_array)` at line 483 and the sole write at line 492, three fallible steps run — identifier decoding (`read_identifier`, line 484), wallet-handle lookup, and the async `sent_contact_requests(&id).await` — any of which can early-return without initializing the output. Because the query depends on a remote Platform response, an unreachable endpoint or malformed identifier input deterministically hits the error path, after which cleanup-on-error at the caller feeds `Box::from_raw` uninitialized/stale pointer+count bits at `platform_wallet_contact_request_handle_array_free`. Same fix: publish `ContactRequestHandleArray::empty()` immediately after the pointer check.
In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:314-369: platform_wallet_create_or_update_dashpay_profile_with_signer leaves *out_profile uninitialized on failure
Reconciled STILL_VALID at bb02c416 (unchanged). `check_ptr!(out_profile)` / `check_ptr!(signer_handle)` at 326-327 are followed by `read_identifier` (329), three `decode_opt_c_str` calls (331-333), a `PLATFORM_WALLET_STORAGE.with_item` handle lookup (343), and the async signer-driven create/update — any of which can return non-OK before `*out_profile = DashPayProfileFFI::from_profile(&profile)` at 367. `DashPayProfileFFI` owns three heap C-string pointers (`display_name`, `public_message`, `avatar_url`) released by `dashpay_profile_ffi_free` via `CString::from_raw`. The blast radius is worse here than in the array cases: three heap slots freed instead of one. Sibling profile getters in this same file already zero-initialize the output before fallible work; write `DashPayProfileFFI::empty()` immediately after the pointer checks so every subsequent early-return leaves the slot safely-freeable regardless of caller cleanup discipline.
In `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:119-135: Silent in-place ABI change on exported dash_sdk_sign_with_mnemonic_resolver_and_path
Reconciled STILL_VALID at bb02c416; the latest delta added the paired-invariant check inside this function but did not version the exported symbol. The `#[no_mangle] pub unsafe extern "C"` signing symbol grew two arguments (`expected_key_data: *const u8`, `expected_key_data_len: usize`) inserted between `network` and the output pointers while retaining its exported name. C symbols carry no ABI version, so any consumer linking against a pre-built xcframework or an external C consumer built against the previous prototype will still link and will pass the old `out_signature` pointer where Rust now reads `expected_key_data`, shifting every following argument. At a signing entry point this becomes type-confused arguments (a heap output buffer reinterpreted as read-only input data of caller-controlled length) — a memory-safety hazard rather than a clean link failure. Version the symbol (e.g. `_v2` / `_with_expected_key`) or keep the old name as a compatibility shim that passes `(null, 0)` internally, so stale linkage fails at load rather than mis-marshaling into a signing function.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs:255-256: Bound expected_key_data_len before constructing the FFI slice
The new binding check at line 255-256 calls `slice::from_raw_parts(expected_key_data, expected_key_data_len)` before the `match expected_key_data_len` at line 260 filters to the accepted lengths (20 or 33). `slice::from_raw_parts` requires the pointer to be valid for reads of `len * size_of::<T>()` bytes; a malformed FFI caller passing a valid pointer with a huge `expected_key_data_len` (e.g. `usize::MAX`) invokes UB at slice construction time even though the match arm would later reject it. The invariant added by the latest delta only enforces null↔0 correspondence, not length bounds. Reject unsupported lengths before forming the slice.
In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/identity_persistence.rs:62-162: IdentityEntryFFI grew in place (208→224 bytes) under unchanged identity persistence callback slot
This PR appends `contact_profiles: *const ContactProfileRowFFI` and `contact_profiles_count: usize` to the exported `#[repr(C)] IdentityEntryFFI`, and the compile-time size guard (line 388) moves from 208 bytes on `origin/v4.1-dev` to 224 bytes at head. The `on_persist_identities_fn` callback name and vtable slot are unchanged. In-tree Swift is regenerated by cbindgen, so it is fine, but any external or stale consumer with the 208-byte header will stride the Rust-provided `Vec<IdentityEntryFFI>` as 208-byte rows: every entry after the first is misparsed from wrong offsets (owned string pointers read from what Rust now treats as inline byte fields) and the last row reads past the allocation. Because these are heap-owned string pointers freed by `free_identity_entry_ffi`, the failure mode is silent memory corruption at persistence time. Same class of concern as prior-7: version the callback slot (e.g. `on_persist_identities_v2_fn`) or the struct name so mixed-version linkage fails at load rather than silently mis-parsing.
In `packages/rs-platform-wallet-ffi/src/contact_persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/contact_persistence.rs:101-220: ContactRequestFFI grew in place (144→184 bytes) under unchanged persistence callback slot
This PR grows `#[repr(C)] ContactRequestFFI` from the base 144 bytes on `origin/v4.1-dev` to 184 bytes at head (size guard at line 220), and `OnPersistContactsFn` gains additional trailing pointer/count arguments — but the callback remains in the same `PersistenceCallbacks.on_persist_contacts_fn` slot with the same name. A stale host callback compiled against the old prototype will still be installed and called; depending on ABI it will parse the upsert array with the 144-byte stride and either receive or ignore the trailing arguments, mismatching the Rust view. As with the IdentityEntryFFI growth and prior-7, this is silent mixed-version FFI mis-parsing at a heap-owned struct array. Version the callback slot/type so old consumers fail at install/link rather than mis-parse memory.
In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169: established_contact_get_note: null *out_note before fallible lookup
Reconciled STILL_VALID at bb02c416 (unchanged). `established_contact_get_note` writes `*out_note = c_str.into_raw()` at line 167 only after handle lookup (162), note-presence (164-165), and `CString::new` (166) all succeed. Stale handles, missing notes, and interior-NUL failures leave the caller's `*mut c_char` slot unchanged, even though the success path returns an owned C-string that must be released with `platform_wallet_string_free` (`CString::from_raw`). The in-repo Swift wrapper zero-inits before the call so this is safe in practice today, but the FFI function should honor the defensive 'always safe to free' contract used elsewhere in this crate by nulling the pointer immediately after `check_ptr!`.
Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer
| /// [`free_identity_entry_ffi`]. Ignore unless | ||
| /// [`Self::dashpay_profile_present`] is `true`. | ||
| pub dashpay_profile_public_message: *const c_char, | ||
| /// Heap-allocated array of [`ContactProfileRowFFI`], one per | ||
| /// **present** cached contact profile on the underlying | ||
| /// [`IdentityEntry::contact_profiles`]. Confirmed-absent entries | ||
| /// (`profile: None`, the negative cache) are NOT projected — they | ||
| /// rebuild harmlessly on the next sync sweep, so persisting them | ||
| /// would only add write churn. Each row owns the same per-string | ||
| /// heap allocations the own-profile block does; every string plus | ||
| /// the outer boxed slice is released in [`free_identity_entry_ffi`]. | ||
| /// `null` when [`Self::contact_profiles_count`] is 0. | ||
| /// | ||
| /// Distinct from `dashpay_profile_*` above: that block is the | ||
| /// owner's *own* profile (one per identity); this array is the | ||
| /// *contacts'* profiles, keyed by each contact's identity id. They | ||
| /// land in separate SwiftData stores on the Swift side. | ||
| pub contact_profiles: *const ContactProfileRowFFI, | ||
| /// Number of rows pointed at by [`Self::contact_profiles`]. `0` | ||
| /// when the identity has no present cached contact profiles. | ||
| pub contact_profiles_count: usize, | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: IdentityEntryFFI grew in place (208→224 bytes) under unchanged identity persistence callback slot
This PR appends contact_profiles: *const ContactProfileRowFFI and contact_profiles_count: usize to the exported #[repr(C)] IdentityEntryFFI, and the compile-time size guard (line 388) moves from 208 bytes on origin/v4.1-dev to 224 bytes at head. The on_persist_identities_fn callback name and vtable slot are unchanged. In-tree Swift is regenerated by cbindgen, so it is fine, but any external or stale consumer with the 208-byte header will stride the Rust-provided Vec<IdentityEntryFFI> as 208-byte rows: every entry after the first is misparsed from wrong offsets (owned string pointers read from what Rust now treats as inline byte fields) and the last row reads past the allocation. Because these are heap-owned string pointers freed by free_identity_entry_ffi, the failure mode is silent memory corruption at persistence time. Same class of concern as prior-7: version the callback slot (e.g. on_persist_identities_v2_fn) or the struct name so mixed-version linkage fails at load rather than silently mis-parsing.
source: ['codex']
| @@ -121,6 +158,34 @@ pub struct ContactRequestRemovalFFI { | |||
| pub contact_id: [u8; 32], | |||
| } | |||
|
|
|||
| /// Flat C mirror of a per-sender **ignore** delta for the `ignored` | |||
| /// array on [`OnPersistContactsFn`]. | |||
| /// | |||
| /// Ignore is a per-sender mute (= block, reversible, local-only); the | |||
| /// suppression key is `(owner_id, sender_id)` — bare sender id, so ALL | |||
| /// of the sender's requests (including rotated, bumped-`accountReference` | |||
| /// ones) are suppressed. The Swift handler persists one row per ignored | |||
| /// sender keyed on that pair so the sender stays suppressed across a | |||
| /// recurring re-sync. | |||
| /// | |||
| /// `is_ignored` is the insert/remove bit: `true` ⇒ persist the | |||
| /// ignored-sender row (from `ContactChangeSet::ignored`); `false` ⇒ | |||
| /// delete it (an un-ignore, from `ContactChangeSet::unignored`). Carrying | |||
| /// both in one array lets the host process a mixed delta in one callback. | |||
| /// | |||
| /// Flat POD (no owned pointers), so the host must copy any row it wants | |||
| /// to retain; nothing is freed on the Rust side. | |||
| #[repr(C)] | |||
| #[derive(Debug, Clone, Copy)] | |||
| pub struct ContactIgnoredSenderFFI { | |||
| /// The wallet-owned identity that ignored the sender (recipient). | |||
| pub owner_id: [u8; 32], | |||
| /// The ignored sender's identity. | |||
| pub sender_id: [u8; 32], | |||
| /// `true` ⇒ persist (ignore); `false` ⇒ delete (un-ignore). | |||
| pub is_ignored: bool, | |||
| } | |||
|
|
|||
| // Compile-time guards. Pin the expected layouts so any reshape on | |||
| // the Rust side fails the cargo build before it can ship a dylib | |||
| // the Swift side will mis-parse at runtime. | |||
| @@ -143,15 +208,50 @@ pub struct ContactRequestRemovalFFI { | |||
| // 128..=131 core_height_created_at u32 | |||
| // 132..=135 (padding to 8) | |||
| // 136..=143 created_at u64 | |||
| // 144 payment_channel_broken bool | |||
| // 145..=151 (padding to 8) | |||
| // 152..=159 alias *const c_char | |||
| // 160..=167 note *const c_char | |||
| // 168 is_hidden bool | |||
| // 169..=175 (padding to 8) | |||
| // 176..=183 contact_account_label *const c_char | |||
| // | |||
| // Total size = 144, alignment = 8 (from u64 / pointer fields). | |||
| const _: [u8; 144] = [0u8; std::mem::size_of::<ContactRequestFFI>()]; | |||
| // Total size = 184, alignment = 8 (from u64 / pointer fields). | |||
| const _: [u8; 184] = [0u8; std::mem::size_of::<ContactRequestFFI>()]; | |||
There was a problem hiding this comment.
🔴 Blocking: ContactRequestFFI grew in place (144→184 bytes) under unchanged persistence callback slot
This PR grows #[repr(C)] ContactRequestFFI from the base 144 bytes on origin/v4.1-dev to 184 bytes at head (size guard at line 220), and OnPersistContactsFn gains additional trailing pointer/count arguments — but the callback remains in the same PersistenceCallbacks.on_persist_contacts_fn slot with the same name. A stale host callback compiled against the old prototype will still be installed and called; depending on ABI it will parse the upsert array with the 144-byte stride and either receive or ignore the trailing arguments, mismatching the Rust view. As with the IdentityEntryFFI growth and prior-7, this is silent mixed-version FFI mis-parsing at a heap-owned struct array. Version the callback slot/type so old consumers fail at install/link rather than mis-parse memory.
source: ['codex']
| if !expected_key_data.is_null() && expected_key_data_len > 0 { | ||
| let expected = std::slice::from_raw_parts(expected_key_data, expected_key_data_len); |
There was a problem hiding this comment.
🟡 Suggestion: Bound expected_key_data_len before constructing the FFI slice
The new binding check at line 255-256 calls slice::from_raw_parts(expected_key_data, expected_key_data_len) before the match expected_key_data_len at line 260 filters to the accepted lengths (20 or 33). slice::from_raw_parts requires the pointer to be valid for reads of len * size_of::<T>() bytes; a malformed FFI caller passing a valid pointer with a huge expected_key_data_len (e.g. usize::MAX) invokes UB at slice construction time even though the match arm would later reject it. The invariant added by the latest delta only enforces null↔0 correspondence, not length bounds. Reject unsupported lengths before forming the slice.
| if !expected_key_data.is_null() && expected_key_data_len > 0 { | |
| let expected = std::slice::from_raw_parts(expected_key_data, expected_key_data_len); | |
| if !expected_key_data.is_null() { | |
| if expected_key_data_len != 20 && expected_key_data_len != 33 { | |
| return fail(SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH); | |
| } | |
| let expected = std::slice::from_raw_parts(expected_key_data, expected_key_data_len); |
source: ['codex']
|
The follow-up PR fixing all confirmed findings from the review above is up: #3997 (targets this PR's branch, so it can merge in before this lands). Two review candidates were refuted during verification and are intentionally not addressed there (F10 optimistic-clear, F13 regtest-HRP). 🤖 Generated with Claude Code |
Issue being fixed or feature implemented
Milestone 1 of the DashPay completion plan (
docs/dashpay/SPEC.md, included in this PR with its research base). DashPay's contact-request flow was broken in four independent, previously-unknown ways:send_contact_requestwas rejected by consensus — the broadcast carried a document id derived from the creation entropy but fresh entropy in the transition; drive-abci recomputes the id and rejects withInvalidDocumentTransitionIdError.ExtendedPubKey::encode()form; DIP-15 and both reference mobile clients (iOS dash-shared-core, Android dashj) use the 69-byte compactfingerprint‖chaincode‖pubkey. Our send failed its own 96-byte ciphertext check; our receive couldn't parse mobile payloads.What was done?
Three logical commits:
docs(dashpay)— the 7-agent-reviewed implementation spec (protocol reference, per-layer inventory, gaps G1–G15, 5-milestone plan, Swift UI design, test plan) + 6 research files including the cross-client interop desk-check and the testnet key-purpose census.fix(sdk)!— entropy threading (ContactRequestResult.entropyreused at broadcast), the DIP-15 69-byte compact-xpub codec inplatform-encryption+ the SDK callback contract switched to it, and the recipient key-purpose assertion relaxed to DECRYPTION-or-ENCRYPTION.fix(platform-wallet)— new recurringDashPaySyncManager(iterates the wallets map, not the token registry; per-identity log-and-continue); ingest-guard relaxation + sent-side reconcile with idempotent, metadata-preserving merge; Accept adopts an existing on-platform reciprocal instead of re-broadcasting; per-sweep account rebuild (external and receiving accounts) with validate-before-ECDH, guard-drop lock ordering, and a transient/permanent failure policy (payment_channel_brokenflag, persisted + FFI accessor); rejected-request tombstone keyed(owner, sender, accountReference)so rotated requests still surface; 69-byte compact parsing on receive with address-equality pinned; key-purpose envelope aligned with on-chain reality;DashPaySdkWriterseam making the write paths testable.How Has This Been Tested?
TDD throughout — every behavioral fix has a test that was red against the unfixed code and green after (red→green evidence recorded in the SPEC.md M1 DONE notes and the three commit messages):
platform-wallet: 196 lib + 8 integration tests green (was 170 before this branch; +34 new)dash-sdk(--features mocks,offline-testing): 139 lib tests green (incl. the entropy-id and 69→96-byte pins)platform-encryption: 7/7 (the crate's test target previously failed to compile — fixed dev-deps)cargo checkclean onrs-sdk-ffi,platform-wallet-ffi,platform-wallet-storage; clippy clean on touched cratesdp_001..dp_006) is specced to ride the e2e framework in test(platform-wallet): e2e framework + full test suite — triage pins, Found-*/PA-* guards, fail-closed persist, Stage-2 merge #3549 and is explicitly not gated on this PR (SPEC.md Part 7.4)Note
CI:
Rust workspace tests / Tests (macOS)red on 3 pre-existing tests — passing locally.The macOS check fails only on three receiver-payment tests
(
register_contact_account_persists_account_registration,reconcile_records_received_payments_from_receival_utxos,reconcile_does_not_clobber_existing_entry_for_same_txid), all withExternal signable wallet has no private key.These pass locally in every configuration tested (9):
cargo test,cargo nextest(isolated and full platform-wallet suite), the CI feature set,
--all-features, theplatform-wallet-family feature unification, under
cargo llvm-covcoverage, and theexact CI package set (
drive+dpp+drive-abci+…--all-featuresunder coverage) —all on the same macOS/aarch64 as the runner. All green.
The wallet is provably
WalletType::Seed-bearing through every code path (from_seed→Seed; the manager'sinsert_walletstores it verbatim;get_walletreturns a&Wallet),yet only the CI runner reads it as
ExternalSignable. Root cause is a use-after-zeroize inthe
key-walletgit dependency:Wallethas aDropthat zeroizes itsZeroize-derivedwallet_type, so the discriminant can corrupt under a particular memory layout (UB isenvironment-dependent — it manifests on the CI runner but not locally). This is outside
this PR's code — pre-existing branch tests plus an external-dependency bug being tracked for
the key-wallet maintainers; the DashPay changes themselves are correct and green.
Breaking Changes
get_extended_public_keycallback contract forcreate_contact_request/send_contact_requestis now "return the 69-byte DIP-15 compact form" (was an encodedExtendedPubKey); validated before encryption.ContactRequestResultgains a publicentropy: Bytes32field. Thers-sdk-ffiC ABI is unchanged (caller doc contract tightened).contacts.payment_channel_brokencolumn,rejected_contact_requeststable) in the initial migration;ContactChangeSetgains arejectedfield.Checklist:
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation